Linux内核学习之进程管理
1. 进程
1.1 基本概念
操作系统上运行的一个程序。
1.2 进程标志符
系统用一个数字来唯一标志进程,称为PID。
- 当分配的PID到达最大值,则重新从0开始寻找最小的PID分配。
- 通过修改/proc/sys/kernel/pid_max可以修改PID的最大值。
1.3 进程描述符
每个进程也有一个自己的唯一的描述符,这个数据结构非常复杂,包含非常多的字段,以下列出主要关系字段
字段 | 数据结构 | 意义 |
---|---|---|
pid,tgid | pid_t | 进程id和线程组id |
state | int | 进程状态,运行态,可中断等待态,不可中断等待态,暂停态,跟踪态,僵尸态,僵尸撤销态 |
thread_info | thread_info* | 指向线程描述符的指针 |
mm | mm_struct* | 指向内存描述符的指针 |
tty | tty_struct* | 指向关联终端描述符的指针 |
signal | signal_struct* | 指向接受到的信号的描述符 |
fs | fs_struct* | 指向当前目录的文件描述符 |
thread | thread_struct* | 存储线程相关寄存器 |
files | files_struct* | 指向打开的文件描述符链表 |
real_parent | task_struct* | 指向实际父进程描述符 |
parent | task_struct* | 指向当前父进程描述符,通常与real_parent一致,当被另一个进程以ptrace系统调用跟踪时,会变成跟踪进程 |
children | list_head | 孩子进程链 |
sibling | list_head | 兄弟进程链 |
1.4 线程描述符和内核堆栈
内核给每个进程都分配一个内核栈,用于进程运行再内核态时使用,其数据结构如下,在栈底放入了一个线程描述符。线程描述符中有个很重要的字段task,用于指向当前进程的描述符。该8KB的联合体被内核放在内存8KB地址对齐的地方,因此用户态的进程,如果需要获取当前进程描述符,方法很简单,就是将当前CPU的sp寄存器的值的低13位置0,得到的指针就指向线程描述符,再从进程描述符中取得进程描述符的地址。
#define THREAD_SIZE_ORDER 1
#d efine THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)]; // 对应8kB
};
线程描述符、内堆栈和进程描述符的关系图如下:
2. 线程(轻量级进程)
在Linux中,最初是只有进程的概念的,而没有线程的概念,后来为了兼容POSIX标准的线程概念,引入了轻量级进程(light weight process,LWP)的概念,也就是线程,没错,也就是说,线程在Linux中原型也是进程。进程是地址空间上隔离的程序,而线程是共享地址空间的。
看下,例如如下例程:
编译:g++ -g -pthread -o main main.cpp
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* t1_entry(void* )
{
int i=10;
while(i--)
{
printf("thread 1 running\n");
sleep(1);
}
return 0;
}
int main(void)
{
printf("hello world\n");
pthread_t t1;
pthread_create(&t1, NULL, t1_entry, NULL);
pthread_join(t1, NULL);
printf("thread 1 finished\n");
return 0;
}
对main进行调试,并运行几秒后按下ctrl+c暂停程序,然后打印全部堆栈,可以看到两个线程,每个线程中出现了三个数字,Thread 2、Thread 0x7ffff6eb9700 和 LWP 1743663,其中2是线程编号,或序号,调试用,0x7ffff6eb9700 是POSIX中的线程id,由POSIX提供的pthread库进行管理,而最后一个才是我们Linux内核中使用的、真正的进程id,两个线程,即两个轻量级进程,具有两个进程id,后续我们将轻量级进程的pid称为线程id,即tid
(gdb) thread apply all bt
Thread 2 (Thread 0x7ffff6eb9700 (LWP 1743663) "main"):
#0 0x00007ffff6fb3918 in nanosleep () from /lib64/libc.so.6
#1 0x00007ffff6fb381e in sleep () from /lib64/libc.so.6
#2 0x00000000004006ff in t1_entry () at main.cpp:11
#3 0x00007ffff7bb61ca in start_thread () from /lib64/libpthread.so.0
#4 0x00007ffff6ef3e73 in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7ffff7fe6740 (LWP 1743662) "main"):
#0 0x00007ffff7bb76cd in __pthread_timedjoin_ex () from /lib64/libpthread.so.0
#1 0x0000000000400746 in main () at main.cpp:22
3. 线程组(宏观进程)
这里说到进程,指的宏观意义上的进程(我把Linux内核中的进程称为微观意义上的进程),一个进程由多个线程组成,这多个线程称为一个线程组,当创建一个进程时,它至少创建一个线程,这第一个线程就是主线程,或称领头线程,它的pid(后续陈述,用大写的PID表示宏观进程ID,小写pid表示微观进程id)作为整个线程组的PID。
再来看一个例子:
还是之前的代码,将t1_entry稍做修改,利用pthread_setname_np设置一下进程的名字。
注意:虽然POSIX接口是跨unix平台的接口,但是不要再跨平台的代码中使用pthread_setname_np,因为pthread_setname_np不是可移植的,其命名最后的 np 就是表示 not portable,类似带此后缀的其他接口也是如此。
编译:g++ -g -pthread -o main main.cpp
void* t1_entry(void* )
{
pthread_setname_np(pthread_self(), "thread 1"); // set thread name
int i=10;
while(i--)
{
printf("thread 1 running\n");
sleep(1);
}
return 0;
}
然后gdb main调试程序,运行几秒后,ctrl+c暂停,然后另开一个终端界面运行以下命令
top -H -p \`pgrep main\`
# -H 是展示线程信息,top默认是只展示进程,不展示线程的
# -p 只展示进程id等于后面值的进程,这里进程指的宏观进程
# `pgrep main` 是获取main进程的PID值
进入信息界面后,按 f 进入如下界面,添加需要显示的字段,找到 TGID 按空格将它设置为显示(前面有*号),然后按 → 键选中它,按 ↑ 键将它挪到PID前面,便于显示时和PID对比。
* PID = Process Id USED = Res+Swap Size (KiB)
* USER = Effective User Name nsIPC = IPC namespace Inode
* PR = Priority nsMNT = MNT namespace Inode
* NI = Nice Value nsNET = NET namespace Inode
* VIRT = Virtual Image (KiB) nsPID = PID namespace Inode
* RES = Resident Size (KiB) nsUSER = USER namespace Inode
* SHR = Shared Memory (KiB) nsUTS = UTS namespace Inode
* S = Process Status LXC = LXC container name
* %CPU = CPU Usage RSan = RES Anonymous (KiB)
* %MEM = Memory Usage (RES) RSfd = RES File-based (KiB)
* TIME+ = CPU Time, hundredths RSlk = RES Locked (KiB)
* COMMAND = Command Name/Line RSsh = RES Shared (KiB)
TGID = Thread Group Id CGNAME = Control Group name
最后按esc退出 f 界面,输出结果如下:
top - 07:56:45 up 33 days, 4:41, 3 users, load average: 0.04, 0.07, 0.02
Threads: 2 total, 0 running, 0 sleeping, 2 stopped, 0 zombie
%Cpu(s): 0.2 us, 0.3 sy, 0.0 ni, 96.4 id, 0.0 wa, 0.1 hi, 0.1 si, 2.9 st
MiB Mem : 15830.9 total, 877.2 free, 3809.6 used, 11144.0 buff/cache
MiB Swap: 8076.0 total, 8029.4 free, 46.6 used. 10939.7 avail Mem
TGID PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1752235 1752235 root 20 0 24160 2184 2000 t 0.0 0.0 0:00.00 main
1752235 1752238 root 20 0 24160 2184 2000 t 0.0 0.0 0:00.00 thread 1
可以看到,main进程有两个线程,其tgid都是1752235,但是pid不同。
-
c/c++编码过程中,可以通过原始系统调用拿到线程的原始pid,注意是原始系统调用接口(syscall),而不是c库封装了一层的getpid,getpid这个系统调用只能拿到进程的tgid,因此它是指宏观意义上的PID。
-
vfork能创建共享地址空间的进程,但是和pthread_create创建的线程不同,虽然都是共享地址空间,咋一看作用是一样的,其实还是有不同的,最主要的,vfork创建的新进程,拥有新的tgid,而pthread_create创建的轻量级进程,仍共享旧的tgid。根据POSIX信号规范,信号会作用与线程组所有线程,换句话说,你给线程组里任何一个pthread_create创建的线程发送结束信号,那么整个线程组都会终止,比如给之前例子中的thread 1 发送结束信号
kill 1752238
,结果进程如下结束了。但是给vfork创建的进程发送结束信号,只会结束那一个进程。
[root@localhost ~]# ./main
hello world
thread 1 running
thread 1 running
thread 1 running
thread 1 running
thread 1 running
Terminated
- Linux原生创建进程的函数不是fork/vfork(这是规范接口),而是clone,clone非常灵活,包括线程创建的原生函数也是clone,如果你那天发现fork/vfork/pthread_create满足不了你的需求,同时你需求没有跨平台要求,可以试试clone。
4. 进程组
进程组由多个进程组成,同一个进程创建的子进程默认都属于同一个进程组(你可以通过setpgid修改),进程组中,第一个创建的进程的pid即进程组的id(pgid)。
在命令行下,我们每敲下的一行命令,shell都会为它创建一个新的进程组。
比如我们敲下:tail -f .bashrc | grep --color=auto 123 | grep --color=auto 456 &
bash会自动创建3个进程(1个tail,2个grep),并为他们新建一个进程组id。
top - 02:29:27 up 34 days, 23:14, 3 users, load average: 0.07, 0.05, 0.00
Tasks: 3 total, 0 running, 3 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.2 us, 0.4 sy, 0.0 ni, 97.1 id, 0.0 wa, 0.1 hi, 0.0 si, 2.2 st
MiB Mem : 15830.9 total, 766.0 free, 3820.3 used, 11244.6 buff/cache
MiB Swap: 8076.0 total, 8029.4 free, 46.6 used. 10921.1 avail Mem
PGRP PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2639721 2639721 root 20 0 7360 936 864 S 0.0 0.0 0:00.00 tail
2639721 2639722 root 20 0 12144 1112 1016 S 0.0 0.0 0:00.00 grep
2639721 2639723 root 20 0 12144 1108 1012 S 0.0 0.0 0:00.00 grep
5. 会话
有时候,我们把进程组称为一项工作(job),那么会话就可以理解成一组工作,会话中,第一个创建的进程组的pgid即整个会话的id(sid),对于bash作为登录shell,第一个进程组即bash,它的pgid则是本次登录会话的sid。
一个登录会话下可以有很多工作,主要分为前台工作和后台工作,前台工作只有一个,后台可以有多个,通过fg命令将后台的命令放到前台执行,bg可以将挂起的进程放到后台执行(例如,你通过ctrl+z挂起了很多前台进程)
[root@localhost ~] tail | echo # 启动第一个工作
^Z # ctrl+z
[1]+ Stopped tail
[root@localhost ~] tail & # 启动第二个工作
[2] 1785608
[root@localhost ~] sleep 20 & # 启动第三个工作
[3] 1785729
[root@localhost ~] jobs
[1]- Stopped tail | echo
[2]+ Stopped tail
[3] Running sleep 20 &
6. Linux进程链表
进程管理主要通过进程描述符这个数据结构实现,每个进程描述符包含一个pids的数组,它是个数组,包含4个pid结构体,前面讨论过4种id(tid、tgid、pgid、sid),他们分别对应下表0~3,每个pid结构体包含三个部分,分别是id值和两个双向链表(节点)。Linux通过hash链表对这些双向链表进行管理,hash链表也有4个,每个链表的下标是对应id的hash值,哈希链表的list1即其拉链,链上每个节点其id值都不同,但是hash值相同。对于list2链表,则是Linux用于管理线程组、进程组和会话的,同一条list2链上的节点,具有相同的id值,如果是具有相同tgid值,表示他们属于同一线程组,同理可知pgid和sid。
进程亲属关系链表:
7. 进程创建
- 把当前进程描述符拷贝一份,然后修改其中部分内容。
- 申请PID,修改到进程描述符。
- 配置线程组、进程组、会话关系链。
- 复制内核态文件描述符、信号量、内存描述符。
- 将进程描述符挂载到运行队列(注册到调度系统)。
8. 进程切换
- 换页表,即保存cr3到前一进程描述符,将后一进程描述符的值恢复到cr3。
- 换任务状态段(TSS),这一步是相对x86架构而言的,x86在进中断时,会用TSS中的sp字段值加载到sp寄存器,退出时还原(具体参考这篇文章),Linux用到了这一机制来切换内核态堆栈,不同的进程使用不同的内核栈,但是由于Linux只为每个CPU维护一个TSS,而不是每进程,因此,在进程切换时,需要更新TSS中相关寄存器字段的值,将TSS段中部分寄存器内容保存到前一进程描述符,将后一进程描述符的TSS的寄存器内容恢复到TSS段中。
- 换线程局部段(TLS),Linux为每个CPU维护3个线程局部段,供用户程序使用,由于同样不是每进程的变量,因此需要更新。将当前全局描述符表中的三个TLS保存到当前进程描述符,将下一进程描述符中的该字段恢复回去。
- 换硬件寄存器,包括ip、sp、eflags、fs、g等 。将这些寄存器保存到前一进程描述符,将后一进程描述符保存的返回地址恢复到IP寄存器。
ps: 实际上,第5步后,只是从进程1的内核态切换到了进程2的内核态,当进程2从内核态返回时,会恢复用户态硬件上下文。
9. 内核线程
内核线程用于完成一些周期性的内核任务,如内存脏数据刷写到磁盘,维护网络连接,内存交换等等。
- 进程0,所有进程的祖先进程,是Linux创建的第一个进程,每个CPU核都有一个0号进程,进程0的信息无法在用户态看到,也就是ps等命令无法显示,但是可以看到进程1的父进程是0进程。
- 进程1,即所有用户态进程(包括内核线程)的祖先进程,在centos上一般是initd或systemd。
- 内核态线程,仅运行在内核态,对于4GB内存机器,它仅访问高1GB的地址空间。
- 创建内核线程,kernel_thread,属于内核符号,对用户态不可见,可在驱动程序中获取,详细方法参考我另一篇文件《hook系统调用》。
- 内核线程多以字母k开头。
root 2 0 0 Sep20 ? 00:00:04 [kthreadd]
root 6 2 0 Sep20 ? 00:00:00 [kworker/0:0H-events_highpri]
root 9 2 0 Sep20 ? 00:00:17 [ksoftirqd/0]
root 17 2 0 Sep20 ? 00:00:20 [ksoftirqd/1]
root 19 2 0 Sep20 ? 00:00:00 [kworker/1:0H-events_highpri]
root 23 2 0 Sep20 ? 00:00:20 [ksoftirqd/2]
root 25 2 0 Sep20 ? 00:00:00 [kworker/2:0H-events_highpri]
root 29 2 0 Sep20 ? 00:00:19 [ksoftirqd/3]
root 31 2 0 Sep20 ? 00:00:00 [kworker/3:0H-events_highpri]
root 35 2 0 Sep20 ? 00:00:19 [ksoftirqd/4]
root 37 2 0 Sep20 ? 00:00:00 [kworker/4:0H-events_highpri]
root 41 2 0 Sep20 ? 00:00:18 [ksoftirqd/5]
root 43 2 0 Sep20 ? 00:00:00 [kworker/5:0H-events_highpri]
root 47 2 0 Sep20 ? 00:00:16 [ksoftirqd/6]
root 49 2 0 Sep20 ? 00:00:00 [kworker/6:0H-events_highpri]
root 53 2 0 Sep20 ? 00:00:16 [ksoftirqd/7]
root 55 2 0 Sep20 ? 00:00:00 [kworker/7:0H-events_highpri]