Linux内核进程管理
基本概念
进程管理
在Linux内核中用task_struct
描述一个进程,也被称之为进程描述符。该结构体很大,在32位机上有17KB,之所以这么大,是因为要保存一个进程所需要知道的全部信息,如打开的文件,进程地址空间,信号,进程状态等等。进程描述符可以通过thread_info
查找,对于向下生长的栈,这个结构保存在栈底,内核提供了一个接口current_thread_info()->task
用于获取进程描述符。每个进程有一个进程编号,系统支持的最大进程编号可以通过文件
/proc/sys/kernel/pid_max
查看。
进程有三个状态:
- TASK_RUNNING
- 表示进程可以运行,此时进程要么在运行,要么在等待运行
- TASK_INTERRUPTIBLE
- 进程在休眠,等待某种条件发生,当被信号中断的时候,进程就进入RUNNING状态
- TASK_UNINTERRUPTIBLE
- 进程等待某种条件发生,即便被信号中断也不会唤醒
创建进程
用户通过fork()创建子进程,内核中采用一种称为COW的技术,通过copy-on-write的方式来减少复制,也就是当子进程真正要写入的时候才会复制地址空间的页面。所以在写入之前,fork()只会复制父进程的页表并生成一个进程ID号。还有一个vfork(),相比fork()的好处就是不复制父进程的页表,因为不复制页表,所以子进程不能去写地址空间,由于这样的限制,现在已经不推荐用vfork()了。
在Linux中不区分进程和线程,创建线程都是通过clone()来实现的,例如fork()在底层也是调用clone()。
Linux还有一种内核线程,通过kthread_run()
创建。
进程调度
进程调度面临一个问题,就是不同类型的进程,需要不同的调度方式。简单来说就是交互式进程需要快速响应,而批操作进程需要高吞吐量。要调度进程,需要有一些指标来作为参数。最基本的参数是优先级,Linux提供了两种优先级指标。
- nice值的范围是(-20, 19),值越大越友好,因此优先级越低。
- real-time优先级范围是(0, 99),值越大,优先级越高。
时间片用来表示一个进程在被抢占之前需要运行的时间,传统的UNIX系统根据nice值计算时间片。这样的方法简单,但是存在如下一些问题:
- 同低同高问题,假定nice0分配100ms,nice20分配5ms,如果是两个nice0,各执行100ms再切换,如果是两个nice20,则各执行5ms就要切换。
- 非线性问题,如nice0和nice1,分配100ms和95ms,而nice19和nice20,分配10ms和5ms,虽然相邻相差为5ms,但是nice19却是nice20的总时间两倍。
- 时间片受系统计时精度影响。
Linux引入一个叫CFS调度,让每个进程获取总时间的\( \frac{1}{n} \),而nice值则用来增加权重。
多线程与多进程
进程与线程区别
首先要区分一下进程与线程,Linux内核中进程和线程都是一个执行体,所以没有区别。用户空间通过系统调用clone
来复制一个任务,如果是创建线程,则需要传递如下标志:
- CLONE_FILES
- 共享文件描述表
- CLONE_PARENT
- 不用设置父子关系
- CLONE_VM
- 共享内存空间
- CLONE_THREAD
- 共享信号
创建进程需要多做的工作是复制页表、创建COW内存映射。至于上下文切换,如果是线程那么切换代价可能会小一点,因为数据可能已经在CPU cache中,当然即便没有共享切换也很快,这由内核努力来保证。对于多核处理器,不共享反而可能更快,因为同步共享内存代价很大。
归纳用户空间进程与线程的区别如下表所示:
进程 | 线程 |
---|---|
资源分配基本单位 | 调度基本单位 |
上下文切换要切页表、刷新TLB | 上下文切换开销小 |
进程间通信慢 | 线程间通信快 |
进程间独立 | 父进程终止将导致所有子线程终止 |
进程间独立 | 线程调用exit()将导致所有子线程同时灭亡 |
地址空间独立 | 共享地址空间 |
调用fork()创建进程 | 调用库函数创建线程 |
值得一提的是vfork()
,父进程在子进程返回之后才执行,调用vfork()
之后一般会调用execv()
启动一个全新进程,因此不需要复制父进程资源空间。当execv()
返回时父进程就认为子进程结束了,实际上子进程在一个完全独立的空间运行。
这里顺便在比较一下fork和vfork的区别,fork将父进程的内存数据复制到子进程,而vfork则共享父进程的内存数据。而且vfork在执行上是有要求的,子进程先执行,并且在子进程调用exit()/exec()之后父进程才会执行。并且vfork的存在也是一个历史原因,因为最初的fork没有使用COW技术,而很多程序fork之后调用exec来执行外部程序就退出了,所以复制父进程的数据显得很多余,才发明了vfork,而如今fork使用了COW技术,vfork又显得很多余了。比较有趣的是如果你在vfork子进程中调用return语句,因为父子栈共享,所以整个栈就回收了,父进程在接收到子进程退出之后开始执行vfork之后的代码,但是此时父进程的栈已经被回收,程序崩溃。
进程间的独立性比较有助于编写简练的代码,如TCP服务端父进程accept之后返回描述符fda,在fork的子进程中可以直接使用fda,因为子进程会复制文件描述符,父进程就可以继续accept下一个请求。如果换成多线程,子线程就必须自己去复制fda,但是由要保证在父进程执行下一个accept之前复制完成,这就必须要通过通信来完成,处理上就比较麻烦。
进程间通信包括:文件、信号、socket、消息队列、管道/命名管道、信号量、共享内存、内存映射等。而线程间通信除了包括进程间通信的方法外,还有:互斥量、自旋锁、读写锁、条件变量、线程信号、全局变量。进程间的通信要么需要切换内核上下文,要么需要与外设访问,如命名管道、文件,所以效率低。
进程间如果使用共享内存,为了保证数据写入安全需要和信号量一起使用,如果使用消息队列,则因消息队列本身是原子操作而自动互斥。共享内存有比较大的好处,因为共享内存脱离进程,当进程意外终止时不会被回收,可以用来分析故障原因,程序重启之后还有机会继续处理未完成任务。当然也有明显的缺点,如加锁后崩溃重启,再次加锁会死锁,也存在被其它进程误操作的可能。
当父进程先于子进程结束时,孤儿进程将有init收养。子进程先于父进程终止,而父进程由没有调用wait
或waitpid
时,子进程进入僵死状态。
进程间通信
早期的进程间通信只有管道、信号、文件。但是很不灵活,因此后来又引入了共享内存/信号量、内存映射、消息队列。再后来又增加了远程进程通信,即socket。
pipe的通信起始就是利用pipefs来通信,父进程调用pipe创建两个文件描述符,再fork两个子进程,让两个子进程相互通信。相对来说限制比较多,一个只能读,一个只能写。命名管道的好处是允许无亲缘关系的进程之间通信。
共享内存提供一种方式可让两个进程同时访问一块内存,但是本身没有提供同步机制,所以需要和信号量配合使用。另外共享内存有大小限制,具体数值可以查看:cat /proc/sys/kernel/shmmax
。默认值为32MB,可以通过如下命令来修改:
sysctl -w kernel.shmmax=2147483648
也可以将如下命令放到/etc/rc.local
启动文件中。
echo "2147483648" > /proc/sys/kernel/shmmax
不过从安全性考虑,有句格言是:不要通过共享内存来通信,要通过通信来共享内存。
内存映射和共享内存有相似之处,只不过共享内存用key来指定共享区,而内存映射用文件来指定共享区。
消息队列则和命名管道相似,但是可以通过发送消息来避免命名管道的同步和阻塞问题。同管道一样每个数据块有长度限制。
补充文档:
进程池与线程池
一般用数组管理一组进程ID,不用的进程将其挂起,用pause()
、信号量、IPC阻塞皆可。当有任务时将其唤醒,可以用信号将其唤醒,可以用函数指针告诉它该做什么,通过共享内存指定要处理的数据。处理完成之后再执行一次通信通知已经处理完成即可。最后需要回收所有子进程,这可以向各进程发送信号唤醒,改变激活状态让其主动结束,逐个wait()
。
线程池则更为轻量级,调度不用等待额外的资源,通过条件变量就可以控制线程阻塞和激活。线程通信的效率也很高,整个程序结束时通过改变条件让子线程自己结束,最后逐个回收即可。
趣味习题
#include <stdio.h> #include <sys/types.h> #include <unistd.h> #include <sys/wait.h> int main() { int i; for(i = 2; i != 0; --i) { fork(); printf("ppid=%d, pid=%d, i=%d\n", getppid(), getpid(), i); if (flush) fflush(stdout); } wait(NULL); wait(NULL); return 0; }
如果不flush,则结果如下:
ppid=25101 | pid=11464 | i=2 |
ppid=11464 | pid=11466 | i=1 |
ppid=11464 | pid=11465 | i=2 |
ppid=11465 | pid=11467 | i=1 |
ppid=11464 | pid=11465 | i=2 |
ppid=11464 | pid=11465 | i=1 |
ppid=25101 | pid=11464 | i=2 |
ppid=25101 | pid=11464 | i=1 |
如果加上flush,则输出是这样的:
ppid=25101 | pid=11490 | i=2 |
ppid=11490 | pid=11491 | i=2 |
ppid=25101 | pid=11490 | i=1 |
ppid=11490 | pid=11492 | i=1 |
ppid=11490 | pid=11491 | i=1 |
ppid=11491 | pid=11493 | i=1 |
简单分析一下为什么会出现如此奇怪的输出,如果不调用fflush
父进程的内存会复制到子进程,所以子进程在打印的时候,顺带又打印了一次父进程的内容。仔细分析上面的数据关系你就会发现,只会在第一次fork出的子进程才会复制。即A两个子进程BC、只有B会打印A的内容,但是B只有一个子进程D,所以D也会打印B的内容。