hj24.life

进程线程和协程浅析

2020.01.29
不管考研还是工作,进程线程总是一个避免不了的问题,除此之外,协程也是高并发编程必备的技能

这篇博客照旧还是以问题为导向,如果看完能回答出下面这些问题,那么应该就初步掌握了这些点,剩下的部分就需要你不断code,不断深挖技术书籍,在实践中提升自己的理解:

  1. 进程、线程、协程有什么区别?
  2. 线程共享哪些进程资源,有哪些独立资源?
  3. 都说进程线程切换开销大,到底大在哪?
  4. 线程池是什么?它有什么作用?
  5. 你知道有哪些多进程和多线程的使用场景?
  6. 进程和进程之间,线程和线程之间怎么通信?
  7. Python和Golang的协程有什么区别?

搞完概念之后可以来试试code,这里有两道leetcode的线程题:

  1. 按序打印
  2. 交替打印FooBar

进程、线程、协程分别是什么?

应该是很老的一个问题了吧,不管是面试还是操作系统课上都会被问到,这里做一个简单的复习:

  1. 进程是正在运行的程序,由程序,数据和进程控制块组成,它是操作系统资源分配和调度的一个独立单位,进程进程之间是相互隔离的,系统是由一个个独立的进程组成的。举个例子,你开着电脑,同时在用vscode写代码和听音乐,这里,vscode和网易云就是两个独立的进程。

补充:

进程控制块(PCB)里面存放着很多东西,有:

  1. 进程标识符,pid 2)用户可见寄存器,如数据寄存器,地址寄存器等,存放数据,地址 3)程序计数器,存放程序执行的下一条指令的地址 4)程序状态寄存器,保存处理器状态 4)栈指针,系统调用、中断处理时需要用到 5)进程控制信息,包括通信信息,存储管理信息等
  1. 线程是进程的一个实体,是CPU调度的一个基本单位,也是程序执行的基本单位,属于进程的一部分,一个进程内有很多线程,每个线程共享当前进程的资源。举个例子,你在使用网易云时会一边听歌,一遍看歌词,这两个任务就是网易云这个进程下面的两个线程。

  2. 协程,它的定义就是一个用户态的轻量级线程,它是运行在线程内的,一个线程内运行多个协程,并且对比线程开辟的时候就会指定好栈空间的大小 (ulimit -s命令可以查看),协程使用的内存是动态变化的,并且它的切换调度由用户自己控制,省去了线程的系统级别的切换开销并且更省内存。

线程共享哪些进程资源,有哪些独立资源

共享的

  1. 进程用户ID和进程组ID
  2. 进程的当前目录
  3. 进程的代码段
  4. 进程的公有数据(可以用来做线程之间的通信)
  5. 进程打开的文件描述符
  6. 进程的信号处理器

独立的

  1. 线程ID
  2. 寄存器组的值:线程切换时需要保存原有线程的寄存器集合的状态,以便将来恢复
  3. 线程栈
  4. 错误返回码:如果共享的话那被其他线程修改了岂不是完犊子了
  5. 线程的信号屏蔽码:每个线程需要的信号都不同,这个应该由线程自己管理
  6. 线程优先级

都说进程线程切换开销大,到底大在哪

面试时答道进程线程区别的时候,总会说到一个点,线程切换开销比进程小。正常难度下,说到这个面试官也就过了,但是难免会遇到较真的,如果继续问下去,开销大在哪,相信会刷掉很大一部分人。

进程

关于进程的开销可以简单的分个类,直接开销和间接开销

1)直接开销 直接开销主要有以下这几个:

  1. 切换页表
  2. 刷新TLB,也就是页表缓存
  3. 切换寄存器中的数据
  4. 执行操作系统调度器的代码

补充 这里要对页表,TLB做个补充,毕竟面试官想深究,或者只是随口一问这些是什么,答不出来岂不是尴尬了。 要讲页表,首先要提到操作系统的虚拟地址和物理地址。我们的代码中操作的都是虚拟地址,那为什么不直接用物理地址呢? 我的理解是,使用虚拟地址有以下这些好处:

  • 方便多进程的隔离,每个进程的地址空间都可以通过虚拟地址更方便的隔离

  • 虚拟地址空间大于物理地址空间,内存不够时可以从虚拟地址中去获取

为什么要扯到这个呢? 虚拟地址的管理是通过MMU(内存管理单元)来做的,而MMU就是依赖页表实现的,虚拟内存映射到哪一块物理内存就是通过页表来记录的,当然MMU除了做内存映射,还可以做权限保护,在虚拟地址的基础上加上权限管理,可以来决定哪块内存可以访问哪块不可。 而TLB就是对页表的缓存。

2)间接开销 一般进程切换完毕之后,各级缓存的命中率都会大幅下降,当前缓存的代码和数据基本都会失效,又要重新从磁盘中去获取并重新缓存,这比直接读内存的效率低的多,所以间接开销主要花在这部分。

线程

由于线程共享了进程的很多东西,所以线程的切换不会涉及到页表这些,开销会比进程切换开销小。但相较于协程,开销也是有的,具体有以下这些:

  1. 切换上下文,保存并切换寄存器、栈等
  2. CPU调度

比起进程,开销是更小的,但频繁切换,也会有不小的开销。

线程池是什么?它有什么作用?

上面一节提到了,线程的开销虽然相较于进程比较小,但频繁的开辟回收切换,也是一个不小的开销,想像一个高并发的web服务,百万个请求过来,如果创建百万个线程,那系统肯定早就崩了。 为了解决这种问题,有了线程池,简单来说,线程池限制了线程的无限制创建,通过对有限线程的复用来提高执行效率 它的基本工作流程是,当有任务过来之后,从线程池中取一个空闲线程,使用完毕之后再返回给线程池,当线程数超过核心线程数之后,将提交的任务存到任务队列里等待空闲线程,当任务队列满了之后,看有没有超过最大线程数,有的话就要执行拒绝策略(默认情况下会抛出异常,表示不能再处理新任务了,或者也可以抛弃新提交的任务)

你知道有哪些多进程和多线程的使用场景

多进程: 比如gunicorn,nginx,都是有一个master进程和多个worker进程的,有master调度管理worker进程,进程进程之间只进行有限的通信,不传递数据,每个进程中采用IO多路复用技术去管理事件,这是一个典型的多进程的使用情况。

多线程,协程: 爬虫使用多线程,协程比较多,每一个爬取任务出来的时候可以开启一个线程或者是协程去执行爬取任务,多个任务之间不会阻塞,可以极大提高爬虫效率。还有一写web server,每个请求过来会去开启一个线程或是协程处理任务。

进程间通信,线程间通信

上面提到了进程间的通信,那么进程和进程之前,线程和线程之间有哪些通信方式呢?

管道、消息队列、共享内存、信号量、socket、信号

  1. 管道 类似于一个缓存区,一个进程把消息放进去,等待另一个进程去取,是单向传输的。 举个例子,linux的mkfifo命令 开启一个终端运行:
mkfifo test
echo "a test" > test

它开启了一个test管道,向里面放了一个a test的消息,这时候是阻塞的,等待另一个进程去取

开启另一个终端:

cat < test

这时候,另一个终端显示了a test,同时之前的那个终端也不在阻塞,结束了进程

从这个例子可以看出来,管道这种方式虽然使用简单,但是确是单向的

  1. 消息队列 为了解决管道单向传输的缺点,有了消息队列,它也是类似有一个缓存区,一个进程把消息放进队列只有,不用等待,可以去做其他事,而另一个进程需要时再去队列里取那个消息

消息队列这种机制也有缺点,当消息内存很大时,两个进程的存取消息开销会很大

  1. 共享内存 为了解决消息队列的问题,可以使用另一种机制–共享内存,虽然进程间的内存是独立的,但其实操作系统分配的是虚拟内存,需要共享时,只要把两个进程间的一部分拿出来映射到同一块内存上,就是实现内存共享

但这样的缺点就是,多进程情况下会出现内存竞争的情况

  1. 信号量 出现竞争时,就可以通过信号量来解决了,信号量起的作用相当于一个计数器,举个例子,当我们给一块内存的信号量设为1时,当ab两个进程都要去访问同一个内存时,a先访问了,信号量就会减为0,这时候b再想去访问就不能访问到了

  2. socket 前面几个方法都是在同一个主机上的进程间的通信方式,不同主机直接可以用socket来解决

  3. 信号 信号是一种通知机制,信号发送方通过信号来通知接收方某件事的发生,接收方做相应处理,一般信号都会绑定一个处理方法,比如处理函数或者设置忽略这个信号

  • 线程与线程的通信方式

锁,信号量,信号

  1. 锁 一般有三种方式: 1)互斥锁:一种排它的锁,防止自己的数据被并发的修改 2)共享锁:允许多个线程并发的读,但不能写 3)条件变量:搭配互斥锁使用,当某个条件为真时才能执行,对条件的判断要在互斥锁的保护下进行

  2. 信号量 和进程的信号量机制类似

  3. 信号 和进程的信号机制类似

Python的协程和Go的协程有什么区别

回顾了一下上面的基础概念,我想用过Python和Go的同学可能会有这样的疑问,Python和Go的协程有什么区别?

  1. 总结来说,Python的协程是1:N的,一个线程内运行多个协程,而Go的协程是M:N的,N个协程会映射到M个线程上;

  2. Go的协程本质上还是对系统线程的调用,而Python的协程是基于Event Loop模型的,简单的说就是同步任务在主线程上排队运行形成一个执行栈,而一些IO请求作为异步任务放到任务队列中,一旦执行栈中的任务执行完毕,主线程就去任务队列中取异步任务开始执行,不断重复以上的步骤。

  3. 基于上面的原因,Go可以做到真正的并行,而Python只能做到并发执行

  4. 此外因为Python对协程支持的晚,所以它最大的问题是大多数第三方库都对asyncio的支持不太好,要使用asyncio的话遇到阻塞操作还是要去开启一个线程执行。替代的方案倒是有,可以使用gevent的monkey patch的模式去运行,不过这又会导致出一些奇奇怪怪的问题,框架层面也可以通过celery来做异步任务

例题

  1. leetcode 1114 按序打印

是一条线程通信题,leetcode上还有其它类似的题,比如经典的哲学家进餐问题,大家可以去练练。 题解如下:

import threading


class Foo:
    def __init__(self):
        self.l1 = threading.Lock()
        self.l1.acquire()
        self.l2 = threading.Lock()
        self.l2.acquire()


    def first(self, printFirst: 'Callable[[], None]') -> None:
        
        # printFirst() outputs "first". Do not change or remove this line.
        printFirst()
        self.l1.release()


    def second(self, printSecond: 'Callable[[], None]') -> None:
        
        # printSecond() outputs "second". Do not change or remove this line.
        self.l1.acquire()
        printSecond()
        self.l2.release()


    def third(self, printThird: 'Callable[[], None]') -> None:
        
        # printThird() outputs "third". Do not change or remove this line.
        self.l2.acquire()
        printThird()
  1. 信号量
import threading

class Foo:
    def __init__(self):
        self.s1 = threading.Semaphore(0)
        self.s2 = threading.Semaphore(0)

    def first(self, printFirst: 'Callable[[], None]') -> None:
        
        # printFirst() outputs "first". Do not change or remove this line.
        printFirst()
        self.s1.release()

    def second(self, printSecond: 'Callable[[], None]') -> None:
        
        # printSecond() outputs "second". Do not change or remove this line.
        self.s1.acquire()
        printSecond()
        self.s2.release()

    def third(self, printThird: 'Callable[[], None]') -> None:
        
        # printThird() outputs "third". Do not change or remove this line.
        self.s2.acquire()
        printThird()
        self.s2.release()
  1. 交替打印FooBar

直接用互斥锁

from threading import Lock

class FooBar:
    def __init__(self, n):
        self.n = n
        self.bl = Lock()
        self.al = Lock()
        self.bl.acquire()

    def foo(self, printFoo: 'Callable[[], None]') -> None:
        
        for i in range(self.n):
            self.al.acquire()
            # printFoo() outputs "foo". Do not change or remove this line.
            printFoo()
            self.bl.release()


    def bar(self, printBar: 'Callable[[], None]') -> None:
        
        for i in range(self.n):
            self.bl.acquire()
            # printBar() outputs "bar". Do not change or remove this line.
            printBar()
            self.al.release()

参考

  1. 阿里面试真题解析之进程和线程的区别
  2. Js的Event Loop模型
  3. cpu 为什么要使用虚拟地址空间与物理地址空间映射?解决了什么样的问题?
  4. MMU
  5. 线程共享了哪些资源
  6. 线程池
  7. 知乎-Python和Go的协程的区别