Linux线程、线程与异步编程、协程与异步介绍

协程不是系统级线程,很多时候协程被称为“轻量级线程”、“微线程”、“纤程(fiber)”等。简单来说可以认为协程是线程里不同的函数,这些函数之间可以相互快速切换。
协程和用户态线程非常接近,用户态线程之间的切换不需要陷入内核,但部分操作系统中用户态线程的切换需要内核态线程的辅助。
协程是编程语言(或者 lib)提供的特性(协程之间的切换方式与过程可以由编程人员确定),是用户态操作。协程适用于 io 密集型的任务。常见提供原生协程支持的语言有:c++20、golang、python 等,其他语言以库的形式提供协程功能,比如 c++20 之前腾讯的 fiber 和 libco 等等
linux 线程资源消耗分析大脑 && 流水线 && 分工上下文切换可以类比于人脑的工作方式。工作中不断切换工作内容与场景一般非常累且效率低下(这是流水线发明的初衷也是劳动分工要解决的问题),但在同一个场景下有关联的几个子任务之间相互切换并不耗神,这与线程和协程的切换非常相似
人脑支持异步处理,我们的饥饿感可以认为是系统中断;我们的生物钟可以认为是类似于定时器一样的后台硬件;我们的感情、知识、意识都在潜移默化中慢慢发生变化,这说明大脑也有“后台任务”
进程、线程上下文切换下图展示了进程/线程在运行过程 cpu 需要的一些信息(cpu context,cpu 上下文),比如通用寄存器、栈信息(ebp/esp)等。进程/线程切换时需要保存与恢复这些信息
进程/内核态线程切换的时候需要与 os 内核进行交互,保存/读取 cpu 上下文信息。内核态(kernel)的一些数据是共享的,读写时需要同步机制,所以操作一旦陷入内核态就会消耗更多的时间
进程需要与操作系统中所有其他进程进行资源争抢,且操作系统中资源的锁是全局的;线程之间的数据一般在进程内共享,所以线程间资源共享相比如进程而言要轻一些。虽然很多操作系统(比如 linux)进程与线程区别不是非常明显,但线程还是比进程要轻
linux 线程切换耗时分析线程的切换(context switch)相比于其他操作而言并不是非常耗时,如下图所示(2018 年):
linux 2.6 之后 linux 多线程的性能提高了很多,大部分场景下线程切换耗时在 2us 左右。下面是 linux 下线程切换耗时统计(2013 年)
正常情况下线程有用的 cpu 时间片都在数十毫秒级别,线程切换占总耗时的千分之几以内。协程的使用可以将这个损耗进一步降低(主要是去除了其他操作,比如 futex 等)
虽然线程切换对于常见业务而言并不重要,但不是所有语言或者系统都支持一次创建很多线程。32 位系统即使使用了虚内存空间,因为进程能访问的虚内存空间大概是 3gb,所以单进程最多创建 300 多条线程(假设系统为每条线程分配 10m 栈空间)。太多线程也有线程切换触发了缺页中断的风险
创建很多线程(比如 64 位系统下创建 1 万条线程),不考虑优先级且假设 cpu 有 10 个核心,那么每个线程每秒有 1ms 的时间片,整个业务的耗时大概是 (n-1) * 1 + n * 0.001(n−1)∗1+n∗0.001 秒(n 是线程在处理业务的过程中被调度的次数),如果大量线程之间存在资源竞争,那么系统行为将难以预测。所以在有限的资源下创建大量线程是不合理的,服务线程的个数和 cpu 核心数应该在一个合理的比例内。
内存资源占用默认情况下 linux 系统给每条线程分配的栈空间最大是 6~8mb,这个大小是上限,也是虚内存空间,并不是每条线程真实的栈使用情况。线程真实栈内存使用会随着线程执行而变化,如果线程只使用了少量局部变量,那么真实线程栈可能只有几十个字节的大小。系统在维护线程时需要分配额外的空间,所以线程数的增加还是会提高内存资源的消耗
总结如果线程之间没有竞争关系、线程占用的内存资源较少且对延时不是非常敏感或者说线程创建不频繁(数分钟创建一次),那么直接在使用的时候创建新的线程(std::thread+detach/std::async)也是不错的选择
如果业务处理时间远小于 io 耗时,线程切换非常频繁,那么使用协程是不错的选择
协程的优势并不仅仅是减少线程之间切换,从编程的角度来看,协程的引入简化了异步编程。协程为一些异步编程提供了无锁的解决方案,这些将在下文进行介绍
线程与异步编程同步与异步
同步与异步的区别是顺序与并行,同步编程意味着只有前置操作执行完成才能执行后续流程,如上图 ab 和 cd;异步说明二者可以同时执行,如上图中的 ac(这里不区分并发、并行的区别)
常见异步编程方式c++11 async && future
async 与 future 相关知识可参考其他文章,这里不做详细介绍。术语 future(期货)&& promise(承诺) 源自金融领域
下面代码使用多线程实现数据的累加。线程的创建/调度与其他操作会造成了一些消耗,所以少量数据不建议使用多线程
int64_t multi_thread_acc(const std::vector& data) { if (data.size() < elem_num_multi_th_limit) { // 少于一定数量的累加直接使用单线程会更好 return std::accumulate(data.begin(), data.end(), int64_t(0)); } else { auto step = data.size() / used_core_num; // or std::hardware_currency std::vector< std::future> ret_vec; ret_vec.reserve(used_core_num); for (int i = 0; i < used_core_num; i++) { auto lhs_it = data.begin() + i * step; auto rhs_it = (i == used_core_num - 1) ? data.end() : lhs_it + step; ret_vec.emplace_back( // 持续创建少量线程并不会给系统造成太大的压力 std::async([lhs_it, rhs_it] { return std::accumulate(lhs_it, rhs_it, int64_t(0)); })); } int64_t ret = 0; // 阻塞调用 for (auto& fu : ret_vec) { ret += fu.get(); } return ret; }}从上面的代码中可以看出,常规的异步编程手段还是需要一个同步的过程来搜集异步线程的执行结果
reactor/proactor
网络编程的发展与模式大概有下面几种:
每个请求一个线程/进程,阻塞式 io阻塞式 io,线程池非阻塞式 io && io 复用,类似于 reactorleader/folloer 等模式reactor 编程模式是事件驱动的,并以回调(handle)的方式完成具体业务,reactor 有几个基本概念
nonblockingio+iomultiplexing,请参考 epollevent loop,一个监控事件源(epoll fd)的“死循环”// ... 前置设置略while(true) { // event loop nfds = epoll_wait(epollfd, events, max_events, -1); if(nfds == -1){ printf(epoll_wait failedn); exit(exit_failure); } for(int i = 0; i max_iteration) exit(0); if (setjmp(pointpong) == 0) longjmp(pointping, 1); }}通过命令 gcc test.c 编译后执行 ./a.out 7 ,输出如下:
1 : ping-pong2 : ping-pong3 : ping-pong4 : ping-pong5 : ping-pong6 : ping-pong7 : ping-pong协程的特点协程可以自动让出 cpu 时间片。注意,不是当前线程让出 cpu 时间片,而是线程内的某个协程让出时间片供同线程内其他协程运行协程可以恢复 cpu 上下文。当另一个协程继续执行时,其需要恢复 cpu 上下文环境协程有个管理者,管理者可以选择一个协程来运行,其他协程要么阻塞,要么 ready,或者 died运行中的协程将占有当前线程的所有计算资源协程天生有栈属性,而且是 lock free其他协程库ucontext,cpu 上下文管理下面关于 ucontext 的介绍源自:
http://pubs.opengroup.org/onlinepubs/7908799/xsh/ucontext.h.html 。ucontext lib 已经不推荐使用了,但依旧是不错的协程入门资料。其他底层协程库实现可以查看 boost.context / tbox 等,协程库的对比可以参考:https://github.com/tboox/benchbox/wiki/switch
linux 系统一般都有 ucontext 这个 c 语言库,这个库主要用于操控当前线程下的 cpu 上下文。和 setjmp/longjmp 不同,ucontext 直接提供了设置函数运行时栈的方式(makecontext),避免不同函数栈空间的重叠
ucontext 只操作与当前线程相关的 cpu 上下文,所以下文中涉及 ucontext 的上下文均指当前线程的上下文。一般 cpu 有多个核心,一个线程在某一时刻只能使用其中一个,所以 ucontext 只涉及一个与当前线程相关的 cpu 核心
ucontext.h 头文件中定义了 ucontext_t 这个结构体,这个结构体中至少包含以下成员:
ucontext_t *uc_link // next contextsigset_t uc_sigmask // 阻塞信号阻塞stack_t uc_stack // 当前上下文所使用的栈mcontext_t uc_mcontext // 实际保存 cpu 上下文的变量,这个变量与平台&机器相关,最好不要访问这个变量同时,ucontext.h 头文件中定义了四个函数,下面分别介绍:
int getcontext(ucontext_t *); // 获得当前 cpu 上下文int setcontext(const ucontext_t *);// 重置当前 cpu 上下文void makecontext(ucontext_t *, (void *)(), int, ...); // 修改上下文信息,比如设置栈指针int swapcontext(ucontext_t *, const ucontext_t *);getcontext & setcontext#include int getcontext(ucontext_t *ucp);int setcontext(ucontext_t *ucp);getcontext 函数使用当前 cpu 上下文初始化 ucp 所指向的结构体,初始化的内容包括 cpu 寄存器、信号 mask 和当前线程所使用的栈空间
返回值:getcontext 成功返回 0,失败返回 -1。注意,如果 setcontext 执行成功,那么调用 setcontext 的函数将不会返回,因为当前 cpu 的上下文已经交给其他函数或者过程了,当前函数完全放弃了 对 cpu 的“所有权”
应用:当信号处理函数需要执行的时候,当前线程的上下文需要保存起来,随后进入信号处理阶段。可移植的程序最好不要读取与修改 ucontext_t 中的 uc_mcontext,因为不同平台下 uc_mcontext 的实现是不同的
makecontext & swapcontext#include void makecontext(ucontext_t *ucp, (void *func)(), int argc, ...);int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);makecontext 修改由 getcontext 创建的上下文 ucp。如果 ucp 指向的上下文由 swapcontext 或 setcontext 恢复,那么当前线程将执行传递给 makecontext 的函数 func(...)
执行 makecontext 后需要为新上下文分配一个栈空间,如果不创建,那么新函数func执行时会使用旧上下文的栈,而这个栈可能已经不存在了。argc 必须和 func 中整型参数的个数相等。
swapcontext 将当前上下文信息保存到 oucp 中并使用 ucp 重置 cpu 上下文
返回值:swapcontext 成功则返回 0,失败返回 -1 并置 errno。如果 ucp 所指向的上下文没有足够的栈空间以执行余下的过程,swapcontext 将返回 -1
进一步学习有很多协程库的实现是基于 ucontext 的,我们可以在学习这些库的时候顺便学习一下 ucontext 库的使用方法
coroutine,简单的 c 协程库coroutine 是基于 ucontext 的一个 c 语言协程库实现。包含示例代码在内,全部代码行数不超过 300 行,mac&&linux 可以直接编译运行
下面是一段示例代码:
#include #include coroutine.hstruct args { int n; };static void foo(struct schedule* s, void* ud) { struct args* arg = ud; int start = arg- >n; int i; for (i = 0; i start(); do_accept(); }); } tcp::acceptor acceptor_;};int main(int argc, char* argv[]) { asio::io_context io_context; server s(io_context, std::atoi(argv[1])); io_context.run(); return 0;}协程版 echoasio 1.19.2 已经支持 c++20 的协程,作者 github 仓库中已经包含了协程的使用示例(coroutines_ts),下面是其中 echo_server 的示例,使用支持 c++20 标准的编译器可直接编译运行
// g++-10 -fcoroutines -std=c++20 -i. echo_server.cppawaitable echo(tcp::socket socket) { try { char data[1024]; size_t n = 0; for (;;) { n = co_await socket.async_read_some(asio::buffer(data), use_awaitable); co_await async_write(socket, asio::buffer(data, n), use_awaitable); } } catch (std::exception& e) { ... }}awaitable listener() { auto executor = co_await this_coro::executor; tcp::acceptor acceptor(executor, {tcp::v4(), 55555}); for (;;) { tcp::socket socket = co_await acceptor.async_accept(use_awaitable); co_spawn(executor, echo(std::move(socket)), detached); }}int main() { asio::io_context io_context(1); asio::signal_set signals(io_context, sigint, sigterm); signals.async_wait([&](auto, auto) { io_context.stop(); }); co_spawn(io_context, listener(), detached); io_context.run(); return 0;}

安科瑞智慧用电安全云平台解决方案
美国1.62亿美元支持Microchip,保障半导体供应与就业
Android启用3D映射辅助校正,减少75%错误定位
PCI-E延长线哪家性能强?价格越贵性能越强
通信领域的O-RAN知识大全
Linux线程、线程与异步编程、协程与异步介绍
GPS介绍
基于Simulink的无刷直流电机BLDC无位置闭环控制
Apache Doris冷热分层技术对数据存储有何好处
毫米波雷达是雾天驾驶出行的好帮手
三星s8售价:国行6288元起售
本源物联发布5G工业路由设备BC5521
英国巴克莱银行正在计划推出一个加密货币交易平台
小米长江产业基金入股AI芯片研发商晶视科技
关于无人机通信系统测试解决方案的分析和应用
锂电负极材料特性与合浆工艺分析
物联网技术应用于光伏系统中的优势研究
基于WDM驱动程序和总线技术实现D/A数据输出板卡的设计
盘点Power Tester功率循环测试设备的九大优势
微雪电子TSOP32测试座简介