16. 进程、线程与协程本质区别详解

1 前言

线程与进程,这是两个非常通用的概念,并不仅仅局限于语言本身,因为这是操作系统所提供的,无论什么语言,特别是偏底层语言(比如c、c++、rust等),都能通过对应的函数来创建、操作进程与线程。

但同时要注意,操作系统也有不同类型的,比如主流的windows系统、linux系统、mac系统等等。

虽然各个系统都统一有进程与线程这两个概念,但具体的调用方式,也就是函数接口并不完全一致。

所以如果你使用C/C++在不同的系统上开发软件,并且不使用跨平台的库,那么调用的API就不相同,这也就导致你的程序不跨平台,从而开发难度倍增。

而高级的语言则会提供跨平台的库,比如java、python、rust等等,无论什么平台,都给你提供统一的调用接口,从而实现一份代码,跨平台运行,减少程序员的开发量。

至于协程,则是为了提高线程效率而在应用层实现的一个概念,使得一个线程可以拥有多个协程执行任务。

2 进程

首先我们来聊聊进程,最直观的解释就是:一个应用程序就是一个进程

或者更进一步的说,一个windows平台上以.exe后缀结尾的可执行文件,当其运行起来之后就是一个进程。

虽然这么说在现在看来有些不合适了,但对于新手理解起来确实会更加的容易。

我们可以在window系统中的任务管理器中看到当前电脑上所有运行中的进程(应用程序):

image-20230927143101328

调出方式为:Win+X快捷键,然后选择任务管理器菜单。

这个页面就是当前我电脑上运行的所有进程,也就是所有的应用程序。

对于操作系统来说,进程是基本的资源分配单位。

怎么理解?比如上图每个进程后面有CPU、内存等内容,其中列出的就是当前某个进程所占用的系统内存、CPU,也就是系统资源。

所以如果你发现电脑卡了,就可以来到任务管理器中,看看是哪个进程占CPU、内存多,然后将它关闭即可。

这就是进程的基本概念:资源分配的基本单位

并且各个进程是彼此独立的,比如,你QQ崩溃了,一般来说,是不会影响微信的!

但你会发现,任务管理器中很多进程是可以展开的:

image-20230927144021435

比如我在当前浏览器中,打开了本站点,这里就会出现一个标签页。

如果外面的Microsoft Edge是进程,那这些叫什么呢?它们后面也有内存、CPU占用啊!

而这就是前面我说的不合适的原因了,因为现在很多程序都偏爱制作多进程应用

也就是说,一个应用本身是一个进程组,它是一系列进程的集合,而不单单只是一个进程。

这样做肯定是有好处也有坏处的。

好处就是刚刚所说,进程之间相互彼此独立,比如浏览器中,我一个页面崩溃了,并不会导致整个浏览器崩溃,同时也不会影响到其它页面。

但坏处也很明显,因为每个进程都需要分配资源,就会导致多进程应用对资源占有量一般都比单进程要大的多。

不信的话你可以试一试在浏览器中一次性开几十个页面,一般电脑可能就直接卡死了,不过由于现代浏览器会自动将不活跃的页面休眠释放资源,也不一定会卡死。

以一个基本的C语言代码为例:

int main(){}

虽然它什么也没干,但只要你编译执行它,那它就是一个进程!

关系图如下:

graph TD
    OS[系统] --> P1[进程1]
    OS --> P2[进程2]

    P1 -.-> PNote[进程是资源分配单位<br/>多个进程互相独立]

3 线程

进程很容易理解,就是电脑资源分配的基本单位嘛,并且各个进程相互独立。

那么线程呢?

可能你早就看到过线程的概念,只不过你从来没有注意过。

因为在你买手机、电脑的时候,一般都会有一个配置说明:几核几线程

比如我的电脑就是14核20线程:

image.png

一般来说,一个物理核心同时只能执行一个线程,只不过现代CPU大多采用了超线程技术,让某些物理核心可以同时执行两个线程,因此就会出现线程数量比核心数量多的情况。

而CPU核心是干嘛的?当然是执行计算了,换句话说,就是执行代码的。

所以你大概也就能想到线程是什么东西了,它就是CPU执行的基本单位。

也就是说,上面谈到的进程,仅仅只是从电脑分配到了基本的资源,但内部的代码得靠CPU执行才行。

而CPU不能执行进程,只能执行线程,所以你也就能搞清楚两者的关系了:进程包含线程

那我怎么看不到线程呢?

这个确实,线程不容易被看到,但只要你写过代码,就一定用到过。

还是一个最简单的c语言例子:

int main(){}

进程不执行代码,执行代码的是线程,所以只要你编译上面的代码并执行后,就有一个你看不到的线程,会执行main函数。

这个线程是伴随进程产生的,一般被称为主线程,从main函数开始,从开始执行到结束,如果主线程结束了,那么这个进程也就会被自动终止了。

而所谓的多线程,就是由主线程在这个进程的内部再创建几个子线程一起来干活。

比如我的电脑CPU为14核20线程,也就是同时最多能有20个线程一起工作,相比于你只使用一个主线程而言,可以极大提高效率。

线程的优点很明显,就是它依赖进程而生,所以需要的资源量较少。

但缺点也同样的,因为它依赖于进程,所以如果一个进程里的多线程,任何一个线程崩溃了,都会直接影响整个程序。

如果浏览器采用多线程技术开发的话,就意味着只要你某个页面崩溃了,整个浏览器也会直接崩溃。

另一个要点就是,多线程依赖于一个进程,所以多线程之间是可以很方便的共享资源的。

比如在C/C++中,直接定义一个全局变量,所有线程都可以访问,但这样容易出问题,不过这就是多线程编程要解决的问题了。

多进程当然也是可以共享资源的,只不过用到的技术会复杂的多,依靠的是几种比较复杂的进程间通信方式来实现的,感兴趣的可以参考文章:Windows进程间通信方式详解 | 酷程网

关系图如下:

graph TD
    P[进程] --> T1[线程1]
    P --> T2[线程2]
    P --> T3[线程3]

    T1 --> R[共享资源]
    T2 --> R
    T3 --> R

    T1 -.-> TNote[线程是执行单位<br/>依赖进程存在]
    R -.-> RNote[同一进程内线程共享资源<br/>任一线程崩溃可能导致进程崩溃]

4 协程

线程我们在前文已经知道了,它是系统提供的、最小的执行代码单位。

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

可一旦你想要提升程序的运行效率,比如一个比较实际的例子:下载大型网络文件。

由于这类大型文件对服务器的压力太大,很多网站都会进行限流,而方式就是限制单个线程下载的速率。

那么如何绕过这个限制呢?答案就是多线程,每个线程同时下载大文件的不同部分,然后本地拼接,就实现了下载提速。

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

如果不加以控制,你其中一个线程不知道另外一个线程有没有完成下载,那么最后就无法完成文件的拼接。

一般这个时候,我们采取的策略就是:一个线程专门管理文件的分割、下发,并存放于一个全局队列中,而其它线程就从这个全局队列中取出任务下载,最后由管理线程完成文件的拼接。

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

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

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

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

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

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

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

4.1 原理

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

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

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

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

它的运行原理大致如下:

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,此时就可以取出刚才推入的那个数据,做些一下处理,进行下一次循环,回到第一步。

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

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

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

flowchart TD
    A[操作系统] --> B[进程 Process]
    B --> C1[线程 Thread 1]
    C1 --> D1[协程 Coroutine 1-1]
    C1 --> D2[协程 Coroutine 1-2]

    subgraph 线程1调度
        D1
        D2
    end

4.2 示例

想找个协程的示例并不容易,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中打印。

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

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

4.3 应用

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

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

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

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

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

5 总结

最后再来稍微总结一下三者之间的区别。

首先是进程,它是系统分配资源的基本单位,多个进程互相独立,占用资源量较大,本身并不执行代码,一个进程中最少都有一个线程用于执行代码。

然后是线程,它是系统执行程序的基本单位,并且依赖于进程存在,占用资源量较少,同一进程中的多线程可以非常容易的共享资源,任何一个线程崩溃都会导致当前进程的崩溃。

两者并不存在谁好谁坏的说法,在适当的时候使用适当的技术即可。

至于如何使用,这取决于你使用的语言,比如C/C++在windows系统平台创建线程与进程使用的函数:

#include<windows>
int main(){
    CreateProcress(/*很多参数*/);//创建进程
    CreateThread(/*很多参数*/); //创建线程
}

而在linux平台就是:

#include <sys/types.h>
#include <unistd.h>
int main(){
    fork();// 创建进程
    pthread_create(/*参数*/); //创建线程
}

这很繁琐,所以一般开发跨平台程序,都会使用高级语言、或者跨平台的第三方库。

而最后的协程,并不属于系统级,而是软件层面实现的,一般都是相应语言本身提供的框架实现的,我们并不需要深究,直接使用即可,例如rust中的tokio框架,其本身使用起来和单线程并没有什么区别。

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