最近学习自动驾驶系统时,碰到协程的概念。进程和线程已经迷了,又来个协程,看了很多资料后决定作总结,概括三者联系和区别,最后归结到协程在自动驾驶中的应用。初级程序员目标是搞清三者概念并应用到实际中,而资深工程师则需要在系统层面考虑三者的性能及实现代价,直到如今三者仍是Linux内核和各类编程语言持续更新完善的模块之一,所以理清三者的关系、编程应用和考量性能是进阶程序员的必修课。行文的目的,是对进程/线程/协程这一系列繁复的概念和知识点做一个全面的总结,同时尽量做到知识点讲精讲细讲全,甄别模糊概念,同时兼顾源码及编程实现,最后归结到Apollo的协程实现。
本系列文章分九篇讲解:
一开始并没有进程的概念,计算机都是大型机,程序代码是机器码,直接通过穿孔把程序输入到纸带上面,根据纸带里的二进制数据进行逻辑运算(后来进化到电子管、晶体管和现在的集成电路),一个纸带输入完了,接着读取下一个纸带,只有等上一个处理运算结束之后才能排队到下一个。为了改进这种排队等候的低效率问题,就有人发明了批处理系统。批处理时代可以多个纸带一起提交,计算机会集中处理,或者多写几种可能,集中让计算机处理,最后选取一个较好的结果。
为了提升效率,机器码就被汇编语言替代了,从而再也不用一串串二进制数字来写代码了。但是问题也来了,当程序在运行的时候,会一直占用CPU,有可能某个时间在写磁盘数据、读取网络设备数据等,一直霸占着CPU会造成资源的浪费,这时候完全可以把CPU的计算资源让给其他程序,直到数据读写准备就绪后再切换回来。怎么控制这个过程以及管理多个程序间的计算机资源呢?由于程序并发执行具有间断性、失去封闭性和不可再现性,可能会造成执行结果的不可再现,所以用“程序”这个概念已无法描述程序的并发执行,所以必须引入新的概念——进程来描述程序的并发执行,并要对进程进行必要的管理,以保证进程在并发执行时结果可再现。
进程是程序在计算机上的一次执行活动。当你运行一个程序,你就启动了一个进程。显然,程序只是一组指令的有序集合,它本身没有任何运行的含义,只是一个静态实体。而进程则不同,它是程序在某个数据集上的执行,是一个动态实体。它因创建而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被撤消,反映了一个程序在一定的数据集上 运行的全部动态过程。
在讲解进程之前,让我们先看一看现代操作系统的启动过程,看看pid为1的1号进程是怎么来的。
先思考一个经典问题:当按下电源键之后,计算机如何把自己由静止启动起来的?简述如下:
至此,全部启动过程完成。
上面操作系统的启动过程可以描述为上帝创造万物的过程,第一个被创造出来的进程是0号进程,可以理解为BIOS程序,这个进程在操作系统层面是不可见的,但它存在着。0号进程完成了操作系统的功能加载与初期设定,然后它创造了1号进程(init),这个1号进程就是操作系统的“耶稣”。1号进程是上帝派来管理整个操作系统的,所以在用pstree查看进程树可知,1号进程位于树根。再之后,系统的很多管理程序都以进程身份被1号进程创造出来,还创造了与人类沟通的桥梁——shell。从那之后,人类可以跟操作系统进行交流,可以编写程序,可以执行任务等。而这一切,都是基于进程的。
进程(Process)是可并发执行的程序在一个数据集合上的运行过程。从结构上,进程实体由程序段、数据段和进程控制块三部分组成,UNIX中称为“进程映象”。进程具有动态性、并发性、独立性和异步性等。每一个任务(进程)被创建时,系统会为他分配存储空间等必要资源,然后在内核管理区为该进程创建管理节点,以便后来控制和调度该任务的执行。进程真正进入执行阶段之前,还需要获得CPU的使用权及其它资源,这一切都是操作系统掌管着,也就是所谓的调度。在各种条件满足的情况下,启动进程的执行过程。
对于操作系统而言,进程是核心之核心,它是内存中加载指令的最小单位。整个现代操作系统的根本,就是以进程为单位在执行任务,系统的管理架构也是基于进程层面的。有了上面的引入,我们可以对进程做一个简要的总结:进程,是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。它的执行需要系统分配资源创建实体之后才能进行。
即使划分了资源管理的最小单元,但是一个进程在运行的过程中,不可能一直占据着CPU进行逻辑运算,运行过程中很可能在进行磁盘I/O或者网络I/O,CPU资源还是有些浪费。另外,在执行一些细小任务时,虽然本身无需单独分配内存,但进程的实现机制依然会繁琐的将内存分割,这样既造成内存浪费又消耗时间。为了更加充分利用CPU运算资源,提高内存利用率,有人设计了线程的概念。线程最大的特点就是和创建它的进程共享地址空间,在不需要独立内存资源的情况下就可以运行,并且一个进程可以拥有多个线程,这样在某个线程IO时,无需切换内存,其他线程就可以抢占CPU进行运算。
相对进程而言,线程是一个更加接近于执行体的概念,它可以与同进程中的其他线程共享数据,降低通信开销,可以拥有自己的栈空间和独立的执行序列。一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行。在串行程序基础上引入线程和进程是为了提高程序的并发度,从而提高程序运行效率和响应时间,线程和进程一样,均由操作系统的调度器来统一调度。
下面对线程做一个总结:线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位,线程只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
另外,线程本身的数据结构需要占用内存,频繁创建和销毁线程会加大系统的压力,如果开辟太多线程,系统调度的开销会很大。线程池就是在这样的场景下提出的,线程池可以在初始化的时候批量创建线程,然后用户通过队列等方式提交业务,线程池中的线程进行业务的消费工作,线程池可以降低线程创建和销毁的开销,但是调度的开销还是存在的。
用户线程和内核支持线程的概念以及用户线程和内核线程池的对应关系将在后续讲解,这里主要了解各种技术出现的历史背景。
本质上,进程的出现,除了解决多任务的场景,也反映了当前硬件技术的瓶颈:单个CPU的计算能力不足,所以引入多核,用多进程机制与之配合,后来为降低调度量级而使用线程,为降低创建和销毁的开销而使用线程池,而协程作为更轻量级的线程,以语言内建机制的形式出现,它是对函数的扩展,可以让函数的执行在协程组件的帮助下,能够在特定位置主动的进行挂起和恢复。
因为线程操作存在诸多不便,比如线程切换的随机性和线程Context的跟随,出入栈的保存和恢复,相关数据的锁和读写控制,这才是多线程的复杂性,如果再加异步引起的数据的非连续性和事件的非必然性操作,就更加增强了多线程遇到问题的判别和断点的准确,而协程则规避了这些。另外,由于线程是操作系统的最小执行单元,因此也可以得出,协程是基于线程实现的,协程的创建、切换、销毁都是在某个线程中来进行的。
所谓协程(coroutine),就是协作式程序运行模式,它是一种轻量级的用户态线程,运行在线程之上,在线程的基础上通过分时复用的方式运行多个协程,实现的是一种非抢占式调度,协程执行完成后或在特定位置(基本就是阻塞操作),可以主动让出CPU(比如yield调用)。协程的运行和切换发生在用户态,系统是无感知的,所有也不存在用户态到内核态的切换,代价更低。协程不像进程或线程那样需要让系统负责相关的调度工作,它需要用户自己调用调度器。
个人认为,从无进程到提出进程是操作系统资源管理第一个重大质的飞跃;从进程到线程和线程池是第二大飞跃;从线程和线程池到协程是第三大质的飞跃。
多进程/多任务的出现是为了提升CPU的利用率,特别是I/O密集型运算,不管是多核还是单核,开多个进程必然能有效提升CPU的利用率。但进程间依然有资源利用优化空间,以及进程间通信的麻烦问题。多线程则可以共享同一进程地址空间上的资源,能在更好的利用空闲资源,且不存在进程间通信的麻烦。但线程的创建和销毁会造成资源的浪费。为了降低线程创建和销毁的开销,又出现了线程池的概念,在一开始就创建批量的线程。虽然减少了部分创建和销毁线程所消耗的资源,但调度的开销依然存在。为了提升用户线程的最大利用效率,又提出了协程的概念,可以充分提高单核的CPU利用率,降低调度的开销(协程因不受操作系统资源管理的自动调度,如果需要可以手工或写代码调度)。
为了先有一个直观的认识,而不是一味的陈列文字,下一章讲解进程/线程相关的系统命令。由于协程一般单独实现,与系统无关,故不涉及进程。