简介
介绍协程之前,先来复习一下进程和线程。
- 进程:应用程序的启动实例,是计算机资源分配的最小单位,进程拥有代码和打开的文件资源、数据资源、独立的内存空间。
- 线程:从属于进程,是程序的实际执行者。是计算机调度和执行的最小单位。一个进程至少包含一个线程,为主线程,也可以有更多的子线程,子线程共享进程的文件和数据。线程拥有自己的栈空间。
它们在操作系统中的关系如图:
通常,应用程序是采用单进程多线程的方式,这样既能使用较小的系统资源,又能把占用的资源使用率最大化。
多线程允许程序充分利用多核cpu的优势,实现程序真正的并行运行。
但多线程也存在一定的问题:
- 多线程共享进程的数据,当多个线程可能会同时读写一段数据时,需要使用互斥保证数据一致性
- 线程在不同的cpu核心上调度时,会涉及到运行状态的保存和恢复、上下文切换等开销
- 频繁地线程启动和停止会影响程序执行效率
- 每个线程都会有一个独立的调用栈,线程数量较多时占用较大内存
- 操作系统会有线程数据的上限,超过上限就不能再创建新线程了
为了提高程序的执行效率,往往需要使用异步调用。
- 由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式。
- 这样避免了同步调用方式中的程序等待,在大并发中效率会有大幅提升。
但是异步编程较为复杂,容易出错且不易调试。
那么问题来了,有没有既能利用多线程和异步的优势,又能避免它们随之而来的缺点的技术呢?
没错,是协程。
协程与子例程(函数)一样,协程(coroutine)也是一种程序组件。协程不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用。
由于协程的理念与之前经常使用技术不同,有必要详细讲解一下。
协程
子例程(函数),在所有语言中都是层级调用。比如A调用B,B在执行过程中又调用了C,C执行完毕返回B,B执行完毕返回A,最后是A执行完毕。
很显然,函数调用是通过栈实现的。而一个线程其实就是在执行一系列的函数。
函数调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。
协程看上去也是函数,但执行过程中,在函数内部可中断,然后转而执行别的函数,在适当的时候再返回来接着执行。
注意,在一个函数中中断,去执行其他函数,不是通过函数调用,这有点类似CPU的中断。
比如有两个协程,A执行一些操作,B执行另一些操作。从执行效果上看,A的操作执行一部分,接着是B执行一部分,然后又回到A执行,再回去B执行。从效果上看,运行结果又有点像多线程。
相对于多线程,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),协程的优点是:
- 执行效率极高。因为是函数切换,而不是线程调度切换,不会有线程调度的开销。在实际应用中,需要的线程越多,协程的高效越明显。
- 不需要锁。多线程同步一直是个影响效率的重要因素,也是经常出bug的地方。而协程是个单个线程中,共享资源不需要锁,只需要判断一下状态,效率极高。
这里,有一个问题,多个协程是在单个线程中运行,单个运行同时只能运行在一个核心上,如何利用多核心呢?
这是协程的缺点之一,即不能利用多核资源。因为协程本质上是个子例程(函数)。
另一个缺点是,进行阻塞操作时(如IO),会阻塞掉整个程序。
这两个缺点其实也容易解决:
最简单的方法是使用多线程,同时每个线程中应用协程,这样既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
到这里了,可以打个比方。
在现实的世界中,每个人,每件东西,其实都是在并发地运行着。你在看这篇文章时,你的朋友在高速路上奔驰,你家的小猫咪在睡觉,你公司楼下的树木在进行光合作用…
而协程,就是要做到每个子例程独立地做自己的事情,而各个协程之间并发地运行。
使用Go的同学,包括之前可能使用过Erlang的同学,对这点应该比较熟悉了。因为它们天生支持并发,协程自然不在话下。
c++对协程的支持
c++对协程的支持不如Go、Lua等语言,但也有一些支持协程的开源库可使用,如boost::coroutine、libgo等。
c++20中已经支持协程,使用了 co_await、co_yield、co_return任何一个关键字的函数都是协程。
如下代码使用协程生成数字:
generator<int> get_integers( int start=0, int step=1 ) {
for (int current=start; true; current+= step)
co_yield current;
}
- co_yield会挂起函数执行,并在generator中保存当前状态
- 通过generator返回current的值
- 数字返回后可循环执行
co_await 可在协程之间切换。如果协程需要另一协程的处理结果,只需要co_await它。待它准备好后会立即处理。如果未准备好,协程会等待。
std::future<std::expected<std::string>> load_data( std::string resource )
{
auto handle = co_await open_resouce(resource);
while( auto line = co_await read_line(handle)) {
if (std::optional<std::string> r = parse_data_from_line( line ))
co_return *r;
}
co_return std::unexpected( resource_lacks_data(resource) );
}
- load_data是一个协程,当打开指定的资源时,且解析到所请求数据的位置后,它将生成std :: future并返回。
- open_resource和read_lines可能是异步协程,它们会打开文件并读取数据。
- co_await将load_data的暂停状态和就绪状态与其进度联系起来。
作为用户空间类型之上的最少语言功能集实现的,C++协程的使用非常灵活。
在以后的使用中分享。
小结
- 线程和协程推荐在IO密集型的任务(比如网络调用)中使用,而在CPU密集型的任务中,表现较差。
- 对于CPU密集型的任务,则需要多个线程,绕开GIL的限制,利用所有可用的CPU核心,提高效率。
- 所以大并发下的最佳实践就是多线程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能,如下图: