进程、线程与协程
本文解释进程、线程与协程是什么,以及它们的适用情况,并列举出实际运用例子。
本文只介绍进程、线程、协程在 Linux(UNIX) 系统下的情况。
进程
进程是正在执行的程序的实例,包括程序计数器、寄存器和变量的当前值。
在 Linux 中,进程调用 fork 来创建一个新进程。调用 fork 的称为父进程,创建的新进程称为子进程。
父子进程拥有各自私有的内存映像,也拥有共享的资源:比如打开的文件、一些只读的资源等等。如果一个进程修改了自己的私有内存,另一个是进程无法看到;如果一个进程修改了可写的共享资源,那么另一个进程看到的是修改后的资源。
由于进程数往往大于 CPU 数目,所以 CPU 要在进程之间切换。进程的切换需要保存当前进程的工作,然后加载新进程的工作状态。
进程可分为三种状态:
- 运行态(该时刻进程实际占用 CPU。)
- 就绪态(可运行,但因为其他进程正在运行而停止。)
- 阻塞态(除非某种外部事件发生,否则进程不能运行)
当所有进程处于阻塞态时,CPU 会空转,从而造成浪费,这种情况下采用多进程可以提高 CPU 的利用率。但是由于创建进程需要消耗其他资源(比如内存),所以进程数不能无限大,要选择一个数值使整体资源的消耗率最大。
线程
在传统操作系统中,每个进程有一个地址空间和一个控制线程。进程把相关的资源集中起来存放到自己的地址空间中以便管理,而线程则是在 CPU 上被调度执行的实体。进程拥有一个执行的线程,线程中有一个程序计数器,记录接下来要执行的命令。线程拥有寄存器,用来保存线程当前的工作变量。线程还拥有一个堆栈,用来记录执行的历史。
多线程是在一个地址空间中运行的多个控制线程。除了共享地址空间和其他一些资源,这些线程与进程差不多。
进程中的内容:
- 地址空间
- 全局变量
- 打开文件
- 子进程
- 即将发生的报警
- 信号与信号处理程序
- 账户信息
线程中的内容:
- 程序计数器
- 寄存器
- 堆栈
- 状态
以上“进程中的内容”是所有线程共享的,而“线程中的内容”是每个线程独立的。
使用多线程的主要原因是它们共享一个地址空间和所有可用数据,而由于多进程具有不同的地址空间,所以多进程不能共享地址空间(但是多进程也可以通过共享内存来通信)。所以对于没有相互之间没有联系的线程,使用多进程单线程;对于相互合作,共同完成一个作业的线程,使用单进程多线程。
由于线程共用了进程的很多东西,所以线程比进程更加轻量,因此创建、撤销地更快,在许多系统中,线程比进程的创建快 10 ~ 100 倍。
Linux 的线程是内核线程,所以 Linux 系统的调度是基于线程的,所以多线程也可以利用多核 CPU。
线程局部变量
对一个线程来说是可见的,但对于其他线程是不可见的。
比如由操作系统维护的 errno
。线程1执行 access
以确定它是否允许访问某个文件。操作系统把返回值放到 errno
里。当控制权返回线程1并在线程1读取 errno
之前,调度程序确认线程1已用完 CPU 时间,并决定切换到线程2。线程2执行一个 open
调用,结果失败,导致重写 errno
,于是给线程1的 errno
会永久丢失。
解决办法是引入一个新的库过程,以便创建、设置和读取这些线程范围的全局变量。该调用在堆上或线程专用储存区上分配一个储存空间用来做这个事情。
比如在 Python 中,有 threading.local()
为什么用户态线程比内核线程快?
根据 Difference Between System Call, Procedure Call and Function Call :
当一个用户程序触发一个系统调用,会在用户程序和内核之间发生上下文切换。这意味着用户程序停止执行并保存它的状态(推到栈上)。内核被调入以完成任务。结果返回到调用的用户程序并且内核被调出。
而用户态线程需要保存信息到该线程的寄存器,查看线程表中的就绪线程,并把新线程的信息装入寄存器中。只要堆栈指针和程序计数器一被切换,新的线程又自动投入运行。相比陷入内核而言上下文切换要少很多,开销要小,所以更快,更快的程度是一个数量级或者更多。
那么既然用户态更快,为什么不将进程放到用户态中?
多进程(线程)问题
- 进程间通信
- 两个或多个进程在关键活动中不会出现交叉
- 正确的顺序
由于线程共享地址空间,所以第一个问题很好解决,但线程也存在后两个问题。所以本章中进程的问题、解决方案同样也适用于线程。
进程间通信
通过进程间的公用储存区:比如内存或文件。
竞争条件
多个进程读写某些共享数据,结果取决于进程的运行顺序。
比如 A、B 进程同时向账户金额为 1000 的账户增加 100。A 进程读取到账户金额为 1000,增加 100 后得到 1100,在将 1100 写入账户前,CPU 调度程序将 A 换下将 B 换上。
B 读取到账户金额为 1000,加 100 后设置回去,账户金额变为 1100。B 进程执行完毕,切换到 A 进程,A 进程继续运行,将账户金额设置为 1100,结果账户少加了钱。
共享数据进行访问的程序片段称作临界区。
凡是涉及共享数据的情况都会引发竞争条件。为了避免这个错误,我们要阻止多个进程同时读写共享的数据。即确保,当一个进程在使用一个共享数据的时候,其他进程不能使用该共享数据。
方法是,当一个进程想进入临界区时,先检查是否允许进入,若不允许,则该进程将进入阻塞态,直到其收到临界区可用的信号。
忙等待
进程反复检查一个条件是否为真。
忙等待会使 CPU 空转,浪费 CPU,但如果操作者知道忙等待花费的时间会很短,那么可以考虑使用。比如 TSL 的信号量操作,仅需几个毫秒,这与生产者等待缓冲区腾出是完全不同的时间级别。
用户线程中,由于没有时钟停止运行时间过长的线程。结果是忙等待的线程将永远循环下去,绝不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁。所以线程获取所获取锁失败后,可以调用 thread_yield
将 CPU 放弃给另一个线程,这样就没有忙等待,在该线程下次运行时,它将再次对锁进行测试。
协程
协程和线程类似,有自己的程序计数器、寄存器、堆栈、状态,和其他协程共享全局变量等资源。协程和线程的区别是,线程可以并行而协程是协作的:在任何时刻,只有一个协程在运行。并且一个协程会一直运行除非它主动放弃控制权并将其转移给另一个协程。[4]
协程和线程
与线程相比,协程是放弃 CPU 并将其给另一个协程[2],而线程只是放弃 CPU[3]。
协程和生成器
两者都可以多次放弃 CPU,暂停它们的执行并且重新从暂停处执行,区别是协程可以立刻控制它放弃后的执行,而生成器不可以,生成器将控制权还给了生成器的调用者。生成器中的 yield
不会指定要跳到哪个协程而是将一个值传回上一级。
使用场景
uWSGI
uWSGI 可以设置进程数和线程数。但是并没有说明在什么情况下适用。
Nginx[5]
在基于进程或线程处理并发连接的模型中,使用单独的进程或线程来处理每个连接,并且会阻塞在网络或输入、输出操作上。所以 CPU 和内存的利用率低。产生进程或线程需要预先准备运行环境,包括分配内存给堆和栈以及创建新的执行环境。创建这些项目也需要消耗额外的 CPU,并且由于线程频繁的上下文切换,最终会导致很差的性能。Apache 就是使用以上模型。
Nginx 采用模块化、事件驱动、异步、单线程、非阻塞的架构。在一个高效的 run-loop 单线程进程中处理连接,这个进程叫做 worker。
由于 nginx 产生多个 workers 去处理连接,所以可以利用多核 CPU。多个 workers 之间通过共享内存交流。
应该根据磁盘使用和 CPU 使用模式来调整 nginx 的 workers 数量。如果是 CPU 密集型工作,比如处理很多 TCP/IP 连接、处理 SSL 或者压缩,nignx 的 workers 应该与 CPU 核心数一致;如果大部分是 IO 密集型工作,比如从磁盘中提供很多内容,那么 wokers 数量应该增多,具体取决于 IO 工作的具体情况,比如等待时间、磁盘储存类型等。
数据库连接
数据库连接是由客户端驱动决定的,这里以 pymongo 和 motor 作为例子。
pymongo[6]
每个 MongoClient 实例都有一个内置的连接池。连接池按照需求打开 sockets 来支持多线程应用对 MongoDB 的并发操作。
连接池的最大数目是 maxPoolSize
,默认为 100。如果 maxPoolSize
的连接正在被使用,那么下一个请求会一直等待直到有连接可用。
MongoClient 实例会打开一个额外的 socket 来监控 MongoDB 服务器的状态。
motor[7]
不支持多线程和 fork 操作;Motor 只能被用在单线程的应用,比如 Tornado 中。
参考
- 现代操作系统(第3版)- Andrew S·Tanenbaum 第二章 进程与线程
- https://en.wikipedia.org/wiki/Coroutine
- http://man7.org/linux/man-pages/man3/pthread_yield.3.html
- Programming in Lua - "Coroutines" section
- http://www.aosabook.org/en/nginx.html
- http://api.mongodb.com/python/current/faq.html#how-does-connection-pooling-work-in-pymongo
- https://motor.readthedocs.io/en/stable/differences.html#threading-and-forking