进程,线程,协程

一台电脑有很多程序在运行,也就有很多进程,一台单核的计算机给每个进程分配一定的时间(时间片)运行,然后快速切换到写一个进程,因为切换的很快,所以人感受不到程序的暂停,下面看一下时间片是怎么分配的:

时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。

尽管初始时间片基本相等,但是后续被分配到的时间片长短并不是相等的,系统通过测量进程处于“睡眠”和“正在运行”状态的时间长短来计算每个进程的交互性,交互性和每个进程预设的静态优先级(Nice值)的叠加即是动态优先级,最后动态优先级按比例缩放就是要分配给那个进程时间片的长短。

——–维基百科

在操作系统中,有三个重要的概念:进程、线程、协程。

进程:当我们运行一个程序的时候,比如你的IDE或者QQ等,操作系统会为这个程序创建一个进程,这个进程包含了运行这个程序所需的各种资源,可以说它是一个容器,是属于这个程序的工作空间,比如它里面有内存空间、文件句柄、设备和线程等等。进程之间是完全隔绝的,不能共享内存数据,但是可以通过进程间通信(IPC)来共享数据。

线程:线程属于进程的一部分,进程可以创建很多属于自己的子线程,比如要下载一个文件,访问一次网络等等。线程会被操作系统调用,来在不同的处理器上运行编写的代码任务,这个处理器不一定是该程序进程所在的处理。线程占用的资源更少,创建速度更快,所以也被成为 轻量级 进程,并且他们是并行运行的,一个进程内的多个线程可以共享资源,比如:内存空间或其他资源,不用特别麻烦的切换页表、刷新TLB,只要把寄存器刷新一遍就行,能比切换进程开销少点。进程间通信很麻烦,而且速度较慢。

Green threads:进程想要触发新线程,必须与操作系统通信,但并非所有操作系统都支持线程,绿色线程的创建和管理速度更快,因为它们完全绕过操作系统,它们看起来很像是操作系统线程,但实际上并不会像系统线程那样并行运行,而是像协程一样。Fibers,lightweighted threads和green threads都是类似协程的东西,但也有缺点。

协程:自己在进程或线程里面写一个逻辑流调度的东西,这样就节省了上下文切换的消耗,这个就叫协程。举例来说:遇到I\O阻塞问题,可以多开一个线程去执行I/O操作,当阻塞的时候,cpu会切换线程,执行其他线程,等这次I/O完成后,切换回这个线程,执行后续逻辑。对于协程来说,只有一个线程,先发出I/O,阻塞后自己在线程里调度,切换到其他逻辑执行,待I/O结束后,切换并继续执行I/O的后续逻辑,节省了上下文切换的开销。

上下文切换的开销:线程是由CPU进行调度的,CPU的一个时间片内只执行一个线程上下文内的线程,当CPU由执行线程A切换到执行线程B的过程中会发生一些列的操作,这些操作主要有”保存线程A的执行现场“然后”载入线程B的执行现场”,这个过程称之为“上下文切换(context switch)”,这个上下文切换过程并不廉价,如果没有必要,应该尽量减少上下文切换的发生。

并发最佳实践

线程数量过多,上下文切换就会消耗相当多的时间,开销是巨大的。在面对高并发的情况下,单纯的创建线程反而不利于响应。工程中的最佳实践应该是事件驱动 + 异步处理。nginx、netty、nodejs都采用了这样的方案。

Nginx 的整体架构

  • 主进程:负责执行特权操作,如阅读配置文件、绑定套接字、创建 / 通知协调(Signalling)子进程。
  • 工作进程:负责接收和处理连接请求,读取和写入磁盘,并与上游服务器通信。当 NGINX 处于活跃状态时,只有工作进程是忙碌的。
  • 缓存加载器进程:负责将磁盘高速缓存加载到内存中。这个进程在启动时运行后随即退出。
  • 缓存管理器进程:负责整理磁盘缓存的数据保证其不越界。这个进程会间歇性运行。

Nginx 能够实现高性能和可扩展性的关键取决于两个基本的设计选型:

  • 尽可能限制工作进程的数量,从而减少上下文切换带来的开销。默认和推荐配置是让每个 CPU 内核对应一个工作进程,从而高效利用硬件资源。
  • 工作进程采用单线程,并以非阻塞的方式处理多个并发连接。

Nodejs的整体架构

  • 主线程:解析和执行js
  • 异步队列:使用libuv库实现,默认启用4个异步队列线程,可通过process.env.UV_THREADPOOL_SIZE调整线程数量

Nodejs高性能的原因在于自带的异步队列,每次执行完主线程的某个方法,就会切换到异步队列,进行event loop,把那些已经完成的异步操作的回调函数压回js主线程执行栈的队尾,并且nodejs采用了linux的epoll机制,不用遍历整个异步队列,而是直接可以获取到需要调用的回调函数,复杂度为O(1)。

而golang erlang akka 它们的并发使用了go routine ,轻进程,actor ,本质都极其轻量级的程序内部协程。这样就避免了线程的创建销毁和切换。本人也理解不深,不做过多介绍。

select和epoll

最后介绍一下Linux的select和epoll,他们是内核的模块,用于高效的处理I/O,假设现在有一个异步队列线程,里面有100万的I/O请求,什么时候执行请求的回调呢?一种方案是poll(轮询),隔一段事件就把100万的请求遍历一遍,执行那些已完成I/O的回调,但是这样太过低效了,复杂度为O(n)。另一种Linux的方案是给这个异步队列线程生成一个select代理,如果某个I/O完成了,select代理就会知道这个线程里有一个I/O完成了,但是它不知道是哪一个请求,Linux有一个epoll模块,可以直接找到这个I/O请求,复杂度为O(1),而不用遍历整个队列。