8.协程与线程

一、前言

本章主要来讨论一下协程这个概念,正如文章标题,它一般会被用来与线程进行比较。

线程我们应该都知道了,它是系统提供的、最小的执行代码单位。

一般正常程序,只要运行起来了,就会默认启动一个线程用来执行我们的代码,所以大多数我们可能并不会关注它。

可一旦你想要提升程序的运行效率,比如一个比较实际的例子:遍历电脑上所有的文件,并分别获取它们的大小

可能你自己都从来没有注意过,一般电脑都至少有几十万个、乃至数百万个文件,比如我的电脑目前就有310万左右的文件。

此时如果你采用传统的单线程遍历、读取,就会非常的耗时,这时候一般我们就会采取多线程的方式了。

多个线程一起去遍历文件,速度自然会提升许多,但随之而来的就是另外一个问题:如何控制各个线程的进度?

如果不加以控制,你其中一个线程如果不知道另外一个线程有没有遍历某个文件夹,然后又将其遍历一遍,那这所谓的多线程就是没有意义的。

一般这个时候,我们采取的策略就是:一个线程专门遍历文件夹,并存放于一个全局队列中,而其它线程就从这个全局队列中取出文件夹,然后遍历其中的文件即可。

也就是经典的:一个生产者与多个消费者问题。

为了控制这多线程共享的队列正常访问,比如你不能让两个线程同时去取到相同的文件夹,一般就会有用到同步锁

而一般同步锁就是一个很消耗性能的东西,并且线程会不断的在阻塞运行这两种状态下切换,同样很耗费性能。

而以上种种问题,在一定程度上就可以通过协程来解决。

二、协程

正如一个进程可以拥有多个线程一样,一个线程是可以拥有多个协程的。

但这个时候你需要注意前面提到的一个基本事实:线程是系统最小的执行代码单元

所以协程并不是系统提供的,而是应用层面提供的一个东西。

它的实现并不容易,所以大部分时候作用程序开发者来说,我们只需要使用即可。

还是拿前面的一个生产者、多个消费者的例子。

此时,假设我们只有一个线程,并在这个线程中创建了多个协程。

这个时候一个有趣的事情就出现了:由于我们从始至终都只有一个线程在执行代码,那么我们就根本不需要同步锁、状态切换之内的东西!

它的运行原理大致如下:

queue q;
void fun1(){
    for(int i=0;i<10000;i++){
        q.push('file'); //推入数据到队列中
        suspend(); //挂起本协程,回到调用者
    }
} 
void fun2(){
	for(int i=0;i<10000;i++){
        resume fun1(); //恢复执行fun1
        f=q.front(); //取出q中的文件
    }
}

上面是一个极其简单的例子,当然,这是无法运行的,各种编程语言的实现虽然有所区别,但其原理就是这样:

  1. 通过一个专门的关键字或者函数来挂起本函数。
  2. 再通过另一个专门的关键字或者函数来恢复这个挂起的函数继续执行。

所以上面那两个函数的运行逻辑大致为:

  1. 首先运行fun2函数,然后在循环体中恢复fun1函数执行。
  2. fun1函数进入循环,向队列中推入一个数据,然后挂起本协程,回到调用者。
  3. 调用者也就是fun2,此时就可以取出刚才推入的那个数据,做些一下处理,进行下一次循环,回到第一步。

注意:此时从始至终,我们都只有一个线程,而通过协程,我们可以实现立即暂停本函数的执行,并回到调用者的目的。

这就是协程最主要的功能:随时挂起本函数,并回到调用者,下一次执行还是从上一次挂起的地方执行。

也因此,此时它是完全不需要任何锁之内的东西的,因为它从始至终,都只有一个线程不断的在两个函数中反复横跳。

三、示例

想找个协程的示例并不容易,C++20太复杂,rust似乎也不支持这么搞。

最后没想到用python居然轻而易举的实现了:

num=0 # 全局变量
def fun1():
    global num
    for i in range(0,100):
        num=i
        yield i #暂停执行并返回
def fun2():
    global num
    f1=fun1() #调用fun1
    for i in range(0,100):
       next(f1) #恢复f1执行
       print(num)
        
fun2() #调用函数fun2

上面就是一个非常简单的协程例子,通过一个全局变量num来记录两个函数互相调用的过程。

首先调用函数fun2,然后进去先调用fun1,得到一个返回值就是fun1中关键字yield返回的一个迭代器对象。

但不同之处在于,每次执行到yield,这个函数就会直接挂起并回到调用者,也就是fun2函数。

然后fun2函数就在一个循环中,不断用next函数来唤起fun1函数上一次执行的位置,给全局变量num赋值,然后再在fun2中打印。

而由于这里只有一个线程,所以并不需要任何互斥锁之内的东西。

当然,这样写对于效率而言其实并没有太大的提升,毕竟它依旧只是一个单线程。

四、应用

对于我们开发程序员来说,并不需要太深入协程,只需要理解它的运行原理即可。

更多的是直接使用由协程这一特性衍生出来的上层库。

比如rust中有一个名为tokio的库,它就是依靠协程这一特性完成的,但为了效率,它并不是单线程,而是多线程。

这是一个更加复杂的结构:整个程序是一个多线程程序,而每个线程又会被划分为无数个协程。

这是如今协程使用的主流架构,虽然其低层使用的多线程,但这个库会自动为我们管理,我们需要做的,仅仅只是创建任意多的协程函数,直接使用即可!

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux