操作系统原理(二),进程、线程

现代操作系统比如,Linux,Windows等,都是支持“多任务”的操作系统。所谓多任务,指的就是操作系统可以同时运行多个任务。也就是在同一台电脑上,可以同时上网、听歌、使用Word,在过去单核的CPU上都已经可以支持多任务,实现的方式是操作系统让各个任务轮流交替执行。,比如任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒,因为CPU执行速度非常块,我们感觉到所有任务都是并发处理。

到了多核CPU时代,由于任务数量还是远远多过CPU的核心数量,所以操作系统也会自动把多任务轮流调度到每个核心上执行。

何为“进程(Process)”,对操作系统来说一个任务就是一个进程,比如打开了迅雷,而进程有可能不止同时干一件事,比如迅雷可以同时下载多个链接,浏览器可以开多个页面等。把进程内的这些“子任务”称为线程(Thread)。

由于每个进程至少要干一件事,所以,一个进程至少有一个线程,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换。

所以现在如果要执行多个任务有两种解决方案。

  • 启动多个进程,每个进程只有一个线程,这样多个进程可以一块执行多个任务
  • 启动一个进程,然后每个进程再启动多个线程,多个线程就可以一块执行任务。
    当然也可以多个进程,再开多个线程,这样模型太复杂,很少使用。

总结一下,多任务有三种方式

  • 多进程模式;
  • 多线程模式;
  • 多进程+多线程模式。

引用知乎上的一个比喻:

1.单进程单线程:一个人在一个桌子上吃菜。
2.单进程多线程:多个人在同一个桌子上一起吃菜。所以多个人同时吃一道菜的时候容易发生争抢,也就是说资源共享就会发生冲突争抢
3.多进程单线程:多个人每个人在自己的桌子上吃菜。

对于Windows来说,加一张桌子开销很大,所以 Windows 鼓励大家在一个桌子上吃菜,所以需要面对线程资源争抢与同步的问题。
对Linux而言,开一张新桌子开销很小,所以可以尽可能多开新桌子,但是在不同桌子上说话不方便,所以需要研究进程间的通信。

那么什么时候适合使用多任务呢?需要执行耗时操作的时候。比如发起一条网络请求,因为网速的原因,服务器不会立刻响应我们 ,如果不开多任务,则主任务会阻塞住,从而影响体验。

现在的服务器的CPU一般会有多个核心。

  • 当每个 CPU 核心运行一个进程的时候,由于每个进程的资源都独立,所以 CPU 核心之间切换的时候无需考虑上下文。
  • 当每个 CPU 核心运行一个线程的时候,由于每个线程需要共享资源,所以这些资源必须从 CPU 的一个核心被复制到另外一个核心,才能继续运算,这占用了额外的开销。
    换句话说,在 CPU 为多核的情况下,多线程在性能上未必赶得上多进程

总结:


image.png

下面详细的介绍一下进程和线程

进程

无操作系统的时候,程序只能顺序执行,当引入了多任务操作系统以后,如果程序之间没有依赖关系 ,则可以并发执行

我们可以将程序的一次执行称为一个进程既然进程是动态的,所以它从创建到结束有一个完整的生命周期,而且CPU等资源是非常有限的,不是所有进程就可以立即占用CPU资源的,所以我们需要一个数据结构记录和描述进程处于什么样的阶段,这样操作系统才可以全局调度。

为了使进程可以并发执行,为进程引入了进程控制块,即进程控制块(Processing Control Block)。这个数据结构记录了进程的相关信息比如要运行的指令地址,进程的状态,CPU暂存器等。也就是说操作系统其实是根据PCB的信息来对并发进程进行控制和管理的。如果没有PCB的话,系统无法感知到进程,所以PCB是进程存在的唯一标志。

那么PCB主要包含什么呢?

  • 进程标识:为进程分配一个数据ID,而且要唯一,相当于有了唯一的地址,操作系统才能找到它。
  • 进程状态信息:也就是进程现在处于什么阶段,优先级如何。
  • 进程控制信息:进程对应程序的地址、消息队列指针等。
  • 存放CPU寄存器中的暂存信息

整个计算机系统中存在多个PCB,为了方便查找,将具有相同状态的PCB通过链表的形式组成一个队列 。而且因为PCB会经常用到,所以常驻内存

进程的状态

进程具有三种基本的状态

  • 就绪:除了还没有获得CPU,其他的都已经准备妥当了。
  • 活动:占有CPU的状态
  • 阻塞:比如请求IO,还没有获得磁盘返回的ACK信号,则会阻塞等待。


    image.png
  • 就绪状态的进程已经准备妥当,只差分配CPU资源,放入就绪队列,一旦获得CPU,则进行执行状态,当时间片用完则又回到就绪状态
  • 运行状态的程序,如果要执行IO请求,则会自己把自己阻塞掉,插入阻塞队列。IO完成以后,又回到就绪状态,插入就绪队列,等待再次调度。

多进程

Unix/Linux操作系统提供了一个fork()系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是fork()调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。

子进程永远返回0,而父进程返回子进程的ID。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程可以很容易拿到父进程的ID。

有了fork调用,一个进程在接到新任务时就可以复制出一个子进程来处理新任务,常见的Apache服务器就是由父进程监听端口,每当有新的http请求时,就fork出子进程来处理新的http请求。

进程通信

进程通信指的是进程之间交互信息或者数据。Process之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信,比如

  • 通过消息Queue: 消息队列是内容为消息的链表
  • 通过管道Pipe:管道是一种半双工的通信方式,数据只能单向流动
  • 通过共享内存:也就是所有进程可以在一段共有的内存上访问数据。
image.png

线程

进程是运行着的程序,已经可以实现多个进程之间的并发了,但是若进程是资源的持有者,每次进程的切换都意味着资源的获得和释放,对性能消耗太大了。所以引入“更轻量的进程”也就是线程。

也就是说进程只作为资源的持有者,然后开若干个线程进行并行处理,同一个进程中的线程共享此进程的资源,只有从一个进程中的线程切换到另一个进程中才会引起进程的调度。

总之,进程是资源的拥有者,而线程变成了独立调度的基本单位,线程本身不拥有资源,只是共享其所在的进程的资源。

进程关注的是内存的占有,每个进程所能访问的内存都是圈好的,而线程作为进程的一部分,扮演的角色就是怎么利用中CPU去运行代码,所以关注的是中央处理器的运行,而不是内存等资源的管理。

线程的实现方式

线程有两种实现方式

  • 内核支持线程:线程的创建、撤销、切换都是依赖于内核空间。好处是SMP(对称处理器)可以调度同一进程中多个线程并行执行。缺点是线程切换的开销比较大,因为线程调度和管理在内核(可以看做高权限状态),但是实际上大多数线程运行在用户态(可以看做低权限状态),所以切换线程需要系统调用,进入内核态,开销自然就大了。
  • 用户级线程:指的是线程创建、同步、通信等不需要使用系统调用来实现,都在用户态完成。好处是与操作系统平台无关,属于用户程序的一部分。比如数据库大多都是这样。但是这样的话,就不能利用多处理器的优点了,一个进程只能分配给一个CPU,而此进程中只有一个线程可执行。

对于用户级线程的实现需要通过中间系统比如运行时系统和内核控制线程。

所谓运行时系统指的是管理和控制线程的函数集合,

第二种方法是通过轻型进程作为中间系统,这些轻型进程可以通过系统调用来获得内核服务,若干个轻型进程形成线程池,而用户级线程运行的时候,只要从池子里面取一个轻型进程连接上,就可以获得如内核进程一样的服务了。

image.png

多线程

多任务可以由多进程完成,也可以由一个进程内的多线程完成。由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程

多线程和多进程最大的不同在于,多进程中,同一个数据,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个数据都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。所以仍然可以给临界区代码加一把,因此其他线程不能同时执行此代码,只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处是

  • 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。
  • 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。
    image.png

进程VS线程

多进程和多线程是多任务最主要的两种方式。要实现多任务,通常会使用计Master-Worker模式,Master负责分配任务,Worker负责执行任务,因此,多任务环境下,通常是一个Master,多个Worker。
多进程模式:

  • 优点:稳定性高,因为一个子进程崩溃了,不会影响主进程和其他子进程。(当然主进程挂了所有进程就全挂了,但是Master进程只负责分配任务,挂掉的概率低)。Apache最早就是采用多进程模式。
  • 缺点:创建进程的代价大,Unix/Linux系统的进程创建开销比较小,但是在Windows下创建进程开销巨大。而且,操作系统能同时运行的进程数也是有限的,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统连调度都会成问题。

对多线程模式而言,多线程模式通常比多进程快一点,但是也快不到哪去。而且,多线程模式最大的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存。在Windows下,多线程的效率比多进程要高,所以微软的IIS服务器默认采用多线程模式。由于多线程存在稳定性的问题,IIS的稳定性就不如Apache。

其实不管是多线程还是多进程,一旦数量上去了,效率都不会太高。如果做作业,先花1小时做语文作业,做完了,再花1小时做数学作业,这样,依次全部做完,一共花5小时,这种方式称为单任务模型,或者批处理任务模型。

然后到了多任务模型,做1分钟语文,再切换到数学作业,做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务是一样的了,看上去就正在同时写5科作业。

但是注意,切换是要付出代价的。从语文切到数学,要先收拾桌子上的语文书(保存现场),然后,打开数学课本(这叫准备新环境)。
操作系统在切换也一样,要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,然后硬盘狂响,点窗口无反应,系统处于假死状态。

image.png

异步IO

可以把任务分为计算密集型和IO密集型。

  • 计算密集型:需要进行大量的计算才能完成,比如高清视频解码,此时对CPU的利用率应该是越高越好,不应该让CPU浪费在任务的切换上,所以计算密集型任务同时进行的数量应当等于CPU的核心数。
  • IO密集型:涉及到网络、磁盘IO的任务都是IO密集型任务,特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成,常见的大部分任务都是IO密集型任务,比如Web应用。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。

那么因为CPU和IO的速度有天壤之别,所以如果一个任务在执行的过程中大部分时间都在等待IO操作,最好引入多进程模型或者多线程模型来支持多任务并发执行。
为多个用户服务,每个用户都会分配一个线程,如果遇到IO导致线程被挂起,其他用户的线程不受影响。

这样虽然解决了并发问题,但是线程数量过多,CPU的时间就花在线程切换上了,所以引入了异步IO,如果充分利用的异步IO,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU,这样总的进程数量并不多,操作系统调度非常高效。

那么什么是异步IO呢?

所谓异步IO指的是当需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去进入下一轮消息处理。一段时间后,当IO返回结果时,再通知CPU直接获取IO操作结果

异步IO模型需要一个消息循环,在消息循环中,主线程不断地重复“读取消息-处理消息”这一过程,也就是说在“发出IO请求”到收到“IO完成”的这段时间里,异步IO模型的主线程并没有休息,而是在消息循环中继续处理其他消息,而不是像同步IO模型下,主线程只能挂起。这样,一个线程就可以同时处理多个IO请求,并且没有切换线程的操作。

image.png

推荐阅读更多精彩内容