进程线程协程

进程线程协程作为基本概念也是实习常被问的基本知识,正好把这一块稍微简单总结一下。

基础概念

进程

进程是系统资源分配的最小单位,由程序段数据段+PCB组成。

进程拥有代码和打开的文件资源、数据资源、独立的内存空间。

系统中的每个进程都运行在某个进程的上下文中。上下文是由程序正确运行的所需的状态组成的。这个状态包括放在内存中的程序代码和数据,栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符。

内存空间

每个程序都有一片完整连续的逻辑地址空间,这些逻辑空间映射到离散的物理空间。在程序的运行过程,完成虚拟地址到物理地址的转换。进程的地址空间是分段的,存在所谓的数据段,代码段,bbs段,堆,栈等等。

对32位机器来说,虚拟的地址空间大小就是4G,可能实际物理内存大小才1G到2G(请回想虚拟内存知识点)

  • 从0xc000000000到0xFFFFFFFF共1G的大小是内核地址空间,余下的低地址3G空间则是用户地址空间。
  • Code VMA: 程序的代码段,CPU执行的机器指令部分。通常,这一段是可以共享的,即多线程共享进程的代码段。并且,此段是只读的,不能修改。
  • Data VMA: 程序的数据段,包含已初始化的全局和静态变量data段和未初始化的全局和静态变量bss段。
  • 堆和栈: new或者malloc分配的空间在堆上,需要编程者维护,若没有主动释放堆上的空间,进程运行结束后会被释放。栈上的是函数栈临时的变量,还有程序的局部变量,自动释放。
  • 共享库和mmap内容映射区:位于栈和堆之间,例如程序使用printf,函数共享库printf.o固定在某个物理内存位置上,让许多进程映射共享。mmap是一个系统函数,可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址。
  • 命令行参数: 程序的命令行参数
  • 环境变量:类似于Linux下的PATH,HOME等环境变量,子进程会继承父进程的环境变量。

OS系统调用_process_address_space.png

陷入与中断

运行进程时,cpu 一直处于一个大循环中:取指,更新 PC,执行,取指……。但有些情况下用户程序需要进入内核,而不是执行下一条用户指令。这些情况包括设备信号的发出、用户程序的非法操作(例如引用一个找不到页表项的虚拟地址)。处理这些情况面临三大挑战:1)内核必须使处理器能够从用户态转换到内核态(并且再转换回用户态)2)内核和设备必须协调好他们并行的活动。3)内核必须知道硬件接口的细节。

这部分展开就太多了,详细请移步本博客下另一篇文章:System Call

进程通信

管道pipe

管道是一种半双工的通信方式,数据只能单向流动。管道分为pipe(无名管道)和fifo(命名管道)两种,除了建立、打开、删除的方式不同外,这两种管道几乎是一样的,都是通过内核缓冲区实现数据传输。

  • pipe用于相关进程之间的通信,例如父进程和子进程,它通过pipe()系统调用来创建并打开,当最后一个使用它的进程关闭对他的引用时,pipe将自动撤销。
  • FIFO即命名管道,提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,类似于直接新建了个文件。pipe只存在于内存中的文件,而FIFO存在于实际的磁盘介质或者文件系统。一旦建立,任何进程都可以通过文件名将其打开和进行读写,而不局限于父子进程,当然前提是进程对FIFO有适当的访问权。当不再被进程使用时,FIFO在内存中释放,但磁盘节点仍然存在。

管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后再缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或慢的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。

消息队列MessageQueue

消息队列,就是一个消息的链表,是一系列保存在内核中消息的列表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。

与管道不同的是消息队列存放在内核中,只有在内核重启(即OS重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。另外,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。

消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。

可以把消息看做一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程可以从消息队列中读取消息。

共享存储SharedMemory

为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制如信号量,配合使用,来实现进程间的同步和通信。

信号量Semaphore

信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

套接字Socket

套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。

信号 ( sinal )

信号是Linux系统中用于进程之间通信或操作的一种机制,信号可以在任何时候发送给某一进程,而无须知道该进程的状态。如果该进程并未处于执行状态,则该信号就由内核保存起来,知道该进程恢复执行并传递给他为止。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。

Linux提供了几十种信号,分别代表着不同的意义。信号之间依靠他们的值来区分,但是通常在程序中使用信号的名字来表示一个信号。在Linux系统中,这些信号和以他们的名称命名的常量被定义在/usr/includebitssignum.h文件中。通常程序中直接包含就好。

信号是在软件层次上对中断机制的一种模拟,是一种异步通信方式,信号可以在用户空间进程和内核之间直接交互。内核也可以利用信号来通知用户空间的进程来通知用户空间发生了哪些系统事件。信号事件有两个来源:

1)硬件来源,例如按下了cltr+C,通常产生中断信号sigint

2)软件来源,例如使用系统调用或者命令发出信号。最常用的发送信号的系统函数是kill,raise,setitimer,sigation,sigqueue函数。软件来源还包括一些非法运算等操作。

一旦有信号产生,用户进程对信号产生的相应有三种方式:

1)执行默认操作,linux对每种信号都规定了默认操作。

2)捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数。

3)忽略信号,当不希望接收到的信号对进程的执行产生影响,而让进程继续执行时,可以忽略该信号,即不对信号进程作任何处理。

TIPS:有两个信号是应用进程无法捕捉和忽略的,即SIGKILL和SEGSTOP,这是为了使系统管理员能在任何时候中断或结束某一特定的进程。

父子进程

在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程无法预测子进程什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或waitpid()系统调用取得子进程的终止状态。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被系统init进程(pid=1)所收养,并由init进程对它们完成状态收集工作。

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

进程状态

下面简单介绍下进程的状态:分linux内核代码定义的状态和常用状态。
由Linux内核代码宏定义出的“TASK_REPORT”:

/* get_task_state() */
#define TASK_REPORT (TASK_RUNNING | TASK_INTERRUPTIBLE |
TASK_UNINTERRUPTIBLE | __TASK_STOPPED |>
__TASK_TRACED | EXIT_ZOMBIE | EXIT_DEAD)

可以得到七个基本的进程状态,即:
R运行状态TASK_RUNNING进程要么正在执行,要么正要准备执行。
S可中断睡眠状态TASK_INTERRUPTIBLE进程被阻塞,直到某个条件变为真。条件一旦达成,进程的状态就被设置为TASK_RUNNING。
D不可中断睡眠状态TASK_UNINTERRUPTIBLE进程不会立即响应信号。这种状态一般用于内核某些不能被打断的进程,比如等待磁盘或网络I / O的设备驱动程序使用。
T停止状态__TASK_STOPPED可以通过发送SIGSTOP信号给进程来停止进程,可以发送SIGCONT信号让进程继续运行
t追踪状态__TASK_TRACED进程被debugger等进程监视。处于TASK_TRACED状态的进程不能响应SIGCONT信号而被唤醒。
Z僵尸状态EXIT_ZOMBIE进程的执行被终止,但是其父进程还没有使用wait()等系统调用来获知它的终止信息。此时进程几乎的所有资源将被回收,没有任何可执行代码,也不能被调度,仅留下task_struct结构(以及少数资源)记载了些供人凭吊的信息。
X死亡状态EXIT_DEAD进程的最终状态,即将被销毁,ls都没了的那种。
常用的状态转换图:

线程

线程是运行在进程上下文的逻辑流。线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程,所以线程的粒度比进程小,线程由内核调度,也有自己的线程上下文,包括一个唯一的整数线程ID, 栈和栈指针,程序计数器,通用目的寄存器和条件码。但是,所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。

内存空间

每个线程独立的线程上下文:一个唯一的整数线程ID, 栈和栈指针,程序计数器,通用目的寄存器和条件码。

和其他线程共享的进程上下文的剩余部分:整个用户虚拟地址空间,那就是上图的只读代码段,读/写数据段,堆以及所有的共享库代码和数据区域,也共享所有打开文件的集合。

线程的寄存器是不共享的,通常栈区是被相应线程独立访问的,但可能出现一个线程去访问另一个线程中的栈区的情况。这是因为这个线程获得了指向另一个线程栈区的指针,那么它就可以读写这个栈的任何部分。

线程崩溃

线程有自己的 stack,但是没有单独的 heap 和 address space。只有进程有自己的 address space,而这个 space 中经过合法申请的部分叫做 process space。Process space 之外的地址都是非法地址。当一个线程向非法地址读取或者写入,无法确认这个操作是否会影响同一进程中的其它线程,所以只能是整个进程一起崩溃。

主线程

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,该线程为程序的主线程(Main Thread)。它是程序开始时就执行的,之后创建的线程都是这个主线程的子线程。每个进程至少都有一个主线程,如main函数。

线程不像进程,一个进程中的线程之间没有父子之分,都是平级关系。理论上,某个线程的退出不会影响其他线程。但是当main执行完之后,return会使编译器调用进程退出的代码exit(),exit() 会让整个进程over终止,那所有线程自然都会退出。

exit() 会让整个进程over终止,那所有线程自然都会退出。

函数压栈

我们先用一个直观的图来理解函数调用,首先main()被装入(这是个广为流传的图,我懒得自己再画了,所以请不要介意它写成了mian)此时main函数运行到了调用func_A()的地方,先在自己的栈帧中压入函数返回地址,然后新开辟了func_A的栈帧并压入系统栈,然后func_A又调用func_B,函数结束后通过返回地址回到上一个函数的代码区。

进程_函数压栈.jpg

这是一个大体的认知,然后我们来说一点细节的,首先我们来回顾一下基本概念:

程序计数器PC指向了当前计算机要执行的指令在存储器中的存放地址,CPU从程序计数器取出当前指令来执行。当指令执行后,程序计数器的值自动增加PC: PC+1,指向下一条将要执行的指令。

指令寄存器EIP保存正在执行的指令。

每个程序都拥有自己的程序栈,栈是向下生长的,即从内存高地址->低地址,那么栈顶的地址要比栈底低。 在C和C++中,临时变量分配在栈中,临时变量拥有函数级的生命周期,即“在当前函数中有效,在函数外无效”。这种现象就是函数调用过程中的参数压栈,堆栈平衡(函数return后,要返还所有使用过的栈空间)所带来的。

基址指针ebp(base pointer),指向了本次函数调用开始时的栈顶指针,它也是本次函数调用时的“栈底”(在一次函数调用中,ebp向下是函数的临时变量使用的空间)函数调用开始时,会用mov ebp, esp把当前的esp保存在ebp中。

栈指针esp(stack pointer),指向当前的栈顶,它是动态变化的,随着我们申请更多的临时变量,esp值不断减小(栈是向下生长的,下图为了理解方便是反着的)函数调用结束,用mov esp, ebp来还原之前保存的esp。

进程_函数栈例.png

在函数调用过程中,ebp和esp之间的空间被称为本次函数调用的“栈帧”。函数调用结束后,处于栈帧之前的所有内容都是本次函数调用过程中分配的临时变量,都需要被“返还”。这样在概念上给了函数调用一个更明显的分界。

线程通信

由于多线程共享地址空间和数据空间,所以多个线程间的通信是一个线程的数据可以直接提供给其他线程使用,而不必通过操作系统,也就是内核的调度。线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

锁机制

  1. 互斥锁提供了以排他方式防止数据结构被并发修改的方法。
  2. 读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
  3. 条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。

信号量机制(Semaphore)

信号量机制包括无名线程信号量和命名线程信号量

信号机制(Signal)

信号机制类似进程间的信号处理

协程

协程Coroutine,作用是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行。协程由于由程序主动控制切换,没有线程切换的开销,所以执行效率极高。

一个线程可以拥有多个协程。协程不被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)

比如python中的yield就是个经典的使用协程的例子,执行到yield后将暂停在这里,CPU去执行其他任务,直到该任务再次被启动(比如数据爬取到了来返回)。

当然最典型的协程例子还是go中的goroutin,

协程与线程

栈大小

首先一般线程都有固定的函数栈大小(2MB)用于保存局部变量,用于在函数切换时使用,goroutin采用了动态扩张收缩的策略:初始化为2KB,最大可扩张到1GB。

有无id

每个线程都有一个id,这个在线程创建时就会返回,所以可以很方便的通过id操作某个线程。但是在goroutine内没有这个概念,这个是go语言设计之初考虑的,防止被滥用,所以你不能在一个协程中杀死另外一个协程,编码时需要考虑到协程什么时候创建,什么时候释放。

GOMAXPROCS

GOMAXPROCS用于设置上下文个数,这个上下文用于协程间的切换,默认值是CPU的个数,即该个数是指定同时执行协程的OS线程数,比如下例,如果只启动一个上下文,虽然一个线程可以有很多个协程,但由于是映射到1个OS线程,1次只能跑一个协程,所以会跑一段时间再进行切换(由调度器进行判断什么时候切换,而不是内核)。第二次启动二个上下文,映射到2个OS线程,同一时间有2个干活的OS线程,所以能看到0和1交替打印。

1
2
3
4
5
6
7
8
9
for {  
go fmt.Print(0)
fmt.Print(1)
}

$ GOMAXPROCS=1 go run example.go
11111111111111111100000000000000000000111111111...
$ GOMAXPROCS=2 go run example.go
01010101010010101001110010101010010101010101010...

调度

线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这代价就小了;另外,协程的切换时间点是由调度器决定的,而不是系统内核决定的,尽管他们切换点都是时间片超过一定阈值,或者进入I/O或睡眠等状态;再次,还有垃圾回收的考虑,因为go实现了垃圾回收,而垃圾回收的必要条件是内存位于一致状态,这就需要暂停所有的线程,如果交给系统去做,那么会暂停所有的线程使其一致,而在go里面调度器知道什么时候内存位于一致状态,就没必要暂停所有运行的协程。

go调度里面有三个角色:三角形M代表内核线程,正方形P代表上下文,圆形G代表协程。一个M对应一个P,一个P下面挂多个G,但一个时候只有一个G在跑,其余都是放入等待队列,等待下一次切换时使用。

进程_goroutine1.jpg

假如一个运行的协程G调用syscall进入阻塞,如图左G0进入阻塞,那么P会转移到另外一个内核线程M1(此时还是1对1)当syscall返回后,需要抢占一个P继续执行,如果抢占不到,G0挂入全局就绪队列runqueue,等待下次调度,理论上会被挂入到一个具体P下面的就绪队列runqueu(区别于全局runqueue)

假如一个P0下面的所有G都跑完了,P0会从别的P1下面就绪队列抢占G进行运行,个数为P1就绪队列的一半。

进程_goroutine2.jpg