Linux系统漫游(二)从进程到并发

漫游式梳理一下关于 Linux 的知识。

上一篇

  • Linux简介与版本
  • 从开机到启动
  • 文件系统
  • 文本流
  • 标准输入、标准输出、标准错误、重定向
  • 管道
  • Linux 架构

本篇:

  • 进程
  • 进程空间
  • 信号
  • 进程间通信
  • 并发与同步

注:本文提取总结自Vamei的博客


Linux 进程

程序

在计算机组成原理中,我们知道计算机只是在重复做一些简单的指令动作,简单地说,计算机只是从内存中取指令或数据然后执行逻辑运算、移位运算或算术运算。而程序(program),就是一系列指令对数据进行运算所构成的集合。

进程

进程是执行程序的过程。同一个程序可以执行多次,每次都可以在内存中开辟独立的空间来装载,从而产生多个进程。不同的进程还可以拥有各自独立的IO接口。

操作系统的一个重要功能就是为进程提供方便,比如说为进程分配内存空间,管理进程的相关信息等等。

在 Linux 中使用ps命令(Process Status)查看进程,用pstree查看进程树

当计算机开机的时候,内核(kernel)只建立了一个init进程。Linux内核并不提供直接建立新进程的系统调用。剩下的所有进程都是init进程通过fork机制建立的。新的进程要通过老的进程复制自身得到,这就是fork。

fork是一个系统调用。进程存活于内存中。每个进程都在内存中分配有属于自己的一片空间 (address space)。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。

父/子进程、孤儿进程、僵尸进程

在 Linux 中,子进程终结时,会通知父进程并清空自身所占内存,并在内核里留下退出信息(exit code,正常退出为0)。父进程得知子进程结束,会对该子进程使用wait系统调用。这个wait函数能从内核中取出子进程的退出信息,并清空该信息在内核中所占据的空间。

但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程。孤儿进程会被过继给init进程,init进程也就成了该进程的父进程。init进程负责该子进程终结时调用wait函数。

当然,一个糟糕的程序(父进程没有回收子进程、释放子进程占用的资源)也完全可能造成子进程的退出信息滞留在内核中的状况,这样的情况下,子进程成为僵尸(zombie)进程。当大量僵尸进程积累时,内存空间会被挤占。

Linux 中的线程

在Linux中,线程只是一种特殊的进程。多个线程之间可以共享内存空间和IO接口。所以,进程是Linux程序的唯一的实现方式。

但是通识上的理解和在高级语言编程中,进程和线程还是有区别的。下面并发一节会讲到。


进程空间

当程序文件运行为进程时,进程在内存中获得空间,那么,进程如何使用内存呢?

如上图,Text区域用来储存指令(instruction),说明每一步的操作。Global Data用于存放全局变量,栈(Stack)用于存放局部变量,堆(heap)用于存放动态变量 (dynamic variable,程序利用malloc系统调用,直接从内存中为dynamic variable开辟空间)。Text和Global data在进程一开始的时候就确定了,并在整个进程中保持固定大小。

malloc是一个C语言函数,它接收一个 int 参数,表示需要向系统申请多少字节(bytes)的内存,并返回申请到的内存地址指针。

栈(Stack)

栈(Stack)以帧(stack frame)为单位。当程序调用函数的时候,比如main()函数中调用inner()函数,stack会向下增长一帧。帧中存储该函数的参数和局部变量,以及该函数的返回地址(return address)。此时,计算机将控制权从main()转移到inner(),inner()函数处于激活(active)状态。

位于栈最下方的帧,和全局变量一起,构成了当前的环境(context)。激活函数可以从环境中调用需要的变量。

当函数又进一步调用另一个函数的时候,一个新的帧会继续增加到栈的下方,控制权转移到新的函数中。当激活函数返回的时候,会从栈中弹出(pop,读取并从栈中删除)该帧,并根据帧中记录的返回地址,将控制权交给返回地址所指向的指令。

堆(Heap)

当程序中使用malloc的时候,堆(heap)会向上增长,其增长的部分就成为malloc从内存中分配的空间。malloc开辟的空间会一直存在,直到我们用free系统调用来释放,或者进程结束。一个经典的错误是内存泄漏(memory leakage),就是指我们没有释放不再使用的堆空间,导致堆不断增长,而内存可用空间不断减少。

溢出(overflow)

栈和堆的大小则会随着进程的运行增大或者变小。当栈和堆增长到两者相遇时候,也就是内存空间图中的绿色区域(unused area)完全消失的时候,再无可用内存。进程会出现栈溢出(stack overflow)的错误,导致进程终止。

在现代计算机中,内核一般会为进程分配足够多的unused area,如果清理及时,栈溢出很容易避免。即便如此,内存负荷过大,依然可能出现栈溢出的情况。我们就需要增加物理内存了。

fork && exec

当一个程序调用fork的时候,实际上就是将上面的内存空间,包括text, global data, heap和stack,又复制出来一个,构成一个新的进程,并在内核中为该进程创建新的附加信息 (比如新的PID,而PPID为原进程的PID)。此后,两个进程分别地继续运行下去。新的进程和原有进程有相同的运行状态(相同的变量值,相同的instructions…)。我们只能通过进程的附加信息来区分两者。

程序调用exec的时候,进程清空自身内存空间的text, global data, heap和stack,并根据新的程序文件重建text, global data, heap和stack (此时heap和stack大小都为0),并开始运行。


Linux 信号

Linux以进程为单位来执行程序。信号(signal)就是一种向进程传递信息的方式。

相对于其他的进程间通信方式(interprocess communication) (比如 pipe, shared memory)来说,信号所能传递的信息比较粗糙,只是一个整数。但正是由于传递的信息量少,信号也便于管理和使用。信号因此被经常地用于系统管理相关的任务,比如通知进程终结、中止或者恢复等等。

信号可以产生于内核或其他进程,但统一由内核(kernel)管理。也就是说,进程间通过信号传递信号时,首先发送给内核,再由内核传递给目标进程。如果一个进程收到信号,会执行对应该信号的操作,称为信号处理(signal disposition)。

从信号的生成到信号的传递的时间,信号处于等待(pending)状态。我们同样可以设计程序,让其生成的进程阻塞(block)某些信号,也就是让这些信号始终处于等待的状态,直到进程取消阻塞(unblock)或者无视信号

Linux 常见信号

  • SIGINT :当键盘按下CTRL+C从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是中断 (INTERRUPT) 该进程。

  • SIGQUIT :当键盘按下CTRL+\从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是退出 (QUIT) 该进程。

  • SIGTSTP: 当键盘按下CTRL+Z从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是暂停 (STOP) 该进程。

  • SIGCONT:用于通知暂停的进程继续。

  • SIGALRM:起到定时器的作用,通常是程序在一定的时间之后才生成该信号。

信号处理

当进程决定执行信号的时候,有下面几种可能:

  • 无视(ignore)信号:信号被清除,进程本身不采取任何特殊的操作

  • 默认(default)操作:每个信号对应有一定的默认操作。比如上面SIGCONT用于继续进程。

  • 自定义操作:也叫做获取 (catch) 信号。执行进程中预设的对应于该信号的操作。


进程间其他方式通信

信号可以看作一种粗糙的进程间通信的方式,用以向进程封闭的内存空间传递信息。但是传递的信息量少,为了让进程间传递更多的信息量,我们需要其他的进程间通信方式。这些进程间通信方式可以分为两种:

管道(PIPE)机制

在 Linux 中可以使用管道将一个进程的输出和另一个进程的输入连接起来,从而利用文件操作API来管理进程间通信。在shell中,我们经常利用管道将多个进程连接在一起,从而让各个进程协作,实现复杂的功能。

管道使用的是Fork机制,只能在有亲缘关系的进程之间传递信息。为了解决这一问题,Linux提供了FIFO方式连接进程。FIFO又叫做命名管道(named PIPE)。

FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。

写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接。

传统IPC (interprocess communication)

主要是指消息队列(message queue),信号量(semaphore),共享内存(shared memory)。这些IPC的特点是允许多进程之间共享资源,这与多线程共享heap和global data相类似。由于多进程任务具有并发性 (每个进程包含一个进程,多个进程的话就有多个线程),所以在共享资源的时候也必须解决同步的问题。


并发与同步

Linux中,虽然线程基于进程,其实现方式也有异于其它的UNIX系统,但Linux的多线程在逻辑和使用上与真正的多线程并没有差别。

多线程

多线程就是允许一个进程内存在多个控制权,以便让多个函数同时处于激活状态,从而让多个函数的操作同时运行。即使是单CPU的计算机,也可以通过不停地在不同线程的指令间切换,从而造成多线程同时运行的效果。

多线程的进程在内存中有多个栈。多个栈之间以一定的空白区域隔开,以备栈的增长。

对于多线程来说,由于同一个进程空间中存在多个栈,任何一个空白区域被填满都会导致stack overflow的问题

并发与竞争

多线程相当于一个并发(concunrrency)系统。并发系统一般同时执行多个任务。如果多个任务可以共享资源,特别是同时写入某个变量的时候,就需要解决同步的问题。

在并发情况下,指令执行的先后顺序由内核决定。同一个线程内部,指令按照先后顺序执行,但不同线程之间的指令很难说清楚哪一个会先执行。如果运行的结果依赖于不同线程执行的先后的话,那么就会造成竞争条件(race condition),在这样的状况下,计算机的结果很难预知。我们应该尽量避免竞争条件的形成。

最常见的解决竞争条件的方法是将原先分离的两个指令构成不可分隔的一个原子操作(atomic operation),而其它任务不能插入到原子操作中。

多线程同步

同步(synchronization)是指在一定的时间内只允许某一个线程访问某个资源 。而在此时间内,不允许其它的线程访问该资源。我们可以通过互斥锁(mutex)条件变量(condition variable)读写锁(reader-writer lock)来同步资源。

互斥锁(mutex)

互斥锁是一个特殊的变量,它有锁上(lock)和打开(unlock)两个状态。互斥锁一般被设置成全局变量。打开的互斥锁可以由某个线程获得。一旦获得,这个互斥锁会锁上,此后只有该线程有权打开。其它想要获得互斥锁的线程,会等待直到互斥锁再次打开的时候。

线程在mutex_lock和mutex_unlock之间的操作时,不会被其它线程影响,就构成了一个原子操作。

条件变量(condition variable)

条件变量除了要和互斥锁配合之外,还需要和另一个全局变量配合,这个全局变量用来构成各个条件。

条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。

读写锁(reader-writer lock)

读写锁与互斥锁非常相似。读写锁有三种状态:

  • 共享读取锁(shared-read)
  • 互斥写入锁(exclusive-write lock)
  • 打开(unlock)。

后两种状态与之前的互斥锁两种状态完全相同。

一个unlock的RW lock可以被某个线程获取R锁或者W锁。

如果被一个线程获得R锁,RW lock可以被其它线程继续获得R锁,而不必等待该线程释放R锁。但是,如果此时有其它线程想要获得W锁,它必须等到所有持有共享读取锁的线程释放掉各自的R锁。

如果一个锁被一个线程获得W锁,那么其它线程,无论是想要获取R锁还是W锁,都必须等待该线程释放W锁。

这样,多个线程就可以同时读取共享资源。而具有危险性的写入操作则得到了互斥锁的保护。