一、进程简介
关于进程相关内容直接看我的操作系统专栏,在这里不再赘述。我们直接快进到Linux中的进程管理部分
二、Linux中的进程描述符
晋城市操作系统中调度的实体,因此需要对进程的信息、所持有的资源进行描述,这种抽象描述称之为进程控制块PCB。和《操作系统》课程中的不一样,真实的Linux中的PCB中存储的信息远多于操作系统课程中的简略表述,因为在进程的生命周期中需要和内核的很多模块进行交互,包括内存管理、进程调度、文件管理等,其中包含的信息如下:
2.1 进程属性的相关信息
进程属性相关信息主要包括和进程状态相关的信息,比如进程状态、进程的PID等信息。其中重要的成员如下。
- state成员:用来记录进程的状态,包括TASK_RUNNING、TASK INTERRUPTIBLE等。
- pid成员:进程唯一的标识符(identifier)。pid被定义为整数类型,pid的默认最大值见
proc/sys/kernel/pid_max
节点。 - flag成员:用来描述进程属性的一些标志位,这些标志位是在
include/linux/sched.h
中定义的。例如,进程退出时会设置PF_EXITING
;进程是 workqueue类型的工作线程时,会设置PF_WQ_WORKER
;fork 完成之后不执行 exec 命令时,会设置PF_FORKNOEXEC
等。 - exit_code 和 exit_signal成员:用来存放进程的退出值和终止信号,这样父进程就可以知道子进程的退出原因。
- pdeath_signal成员:父进程消亡时发出的信号。
- comm 成员:存放可执行程序的名称。
- real _cred 和 cred 成员:用来存放进程的一些认证信息,cred数据结构里包含了uid、gid 等信息。
2.2 进程调度的相关信息
进程需要作为调度实体参与操作系统里的调度,这样就可以实现CPU的虚拟化,也就是每个进程都感觉直接拥有了CPU。宏观上看,各个进程都是并行执行的;但是微观上看,每个进程都是串行执行的。进程调度是操作系统中一个很热门的核心功能,这里先暂时列出 Linux 内核的 task_struct数据结构中关于进程调度的一些重要成员。
- prio 成员:保存着进程的动态优先级,是调度类考虑的优先级。
- static_prio 成员:静态优先级,在进程启动时分配。内核不存储 nice 值,取而代之的是 static_prio。
- normal_prio 成员:基于 static_prio 和调度策略计算出来的优先级。
- rt_priority成员:实时进程的优先级。
- sched_class 成员:调度类。
- se 成员:普通进程调度实体。
- rt 成员:实时进程调度实体。
- dl成员: deadline进程调度实体。
- policy 成员:用来确定进程的类型,比如是普通进程还是实时进程。
- cpus_allowed成员:进程可以在哪几个CPU上运行
第一次看的同学可能会有点神奇,为什么会有几个优先级?Linux的优先级系统是有他独特的机制,相关的问题看我能不能写到,写不到就去问GPT或者Deepseek吧
2.3 进程之间的关系
系统中最初的第一个进程是idle进程(或者叫作进程0),此后的每个进程都有一个创建它的父进程,进程本身也可以创建其他的进程,父进程可以创建多个进程,在进程的家族中,有父进程、子进程,还有兄弟进程。
其中重要的成员如下。
- real_parent成员:指向当前进程的父进程的task_struct 数据结构。
- children 成员:指向当前进程的子进程的链表。
- sibling 成员:指向当前进程的兄弟进程的链表。
- group_leader 成员:进程组的组长。
2.4内存管理和文件管理的相关信息
进程在加载运行之前需要加载到内存中,因此进程描述符必须包含与抽象描述内存相关的信息,还有一个指向mm struct数据结构的指针mm。此外,进程在生命周期内总是需要通过打开文件、读写文件等操作来完成一些任务,这就和文件系统密切相关了。
其中重要的成员如下。
- mm成员:指向进程所管理内存的总的抽象的数据结构mm struct。
- fs成员:保存一个指向文件系统信息的指针。
- files 成员:保存一个指向进程的文件描述符表的指针。
三、进程的生命周期
进程会有不同的状态,包括进程正在运行的运行态、等待执行的就绪态等等,接下来这张图是很经典的操作系统进程状态转换图,详情可参见操作系统专栏
而在Linux中规定了进程的五种状态:
- TASK_RUNNING(可运行态或就绪态):这种状态的描述是正在运行的意思,可是在Linux内核里不一定是指进程正在运行,所以很容易让人混淆。它是指进程处于可执行状态,或许正在执行,或许在就绪队列中等待执行。因此,Linux内核对当前正在执行的进程没有给出明确的状态,不像典型操作系统里给出两个很明确的状态,比如就绪态和运行态。这种状态是运行态和就绪态的集合.
- TASK_INTERRUPTIBLE(可中断睡眠态):进程进入睡眠状态(被阻塞)以等待某些条件的达成或者某些资源的就位,一旦条件达成或者资源就位,Linux内核就可以把进程的状态设置成可运行态(TASK RUNNING)并加入就绪队列。也有人将这种状态称为浅睡眠状态。
- TASK_UNINTERRUPTIBLE(不可中断态):这种状态和上面的TASK_INTERRUPTIBLE 状态类似,唯一不同的是,进程在睡眠等待时不受干扰,对信号不做任何反应,所以这种状态又称为不可中断态。通常,使用ps命令看到的被标记为D状态的进程就是处于该状态的进程,又称为深度睡眠状态
- __TASK_STOP(终止态):进程停止运行了
- EXIT_ZOMBIE(僵尸态):进程已经消亡,但是task_struct数据结构还没有释放,这种状态被称为僵尸状态,是每个进程都会经历的状态。
对于进程状态的设置,可以直接通过操作结构体p->state = TASK_RUNNING
来设置,但是建议还是使用两个Linux提供的接口函数来设置进程状态:
#define set_current_state(state_value) \do{ \smp_store_mb(current->state, (state_value));\}while(0)
进程标识符
在创建时会为进程分配唯一的号码来标识,这个号码就是进程标识符。PID存放在进程描述符的pid字段中,PID是int类型。为了循环使用PID,内核使用bimmap机制来管理当前已经分配的PID和空闲的PID,bitap机制可以保证每个进程在创建时能分配到唯一的号码。
除了PID之外,Linux内核还引入了线程组的概念。一个线程组中的所有线程都使用和该线程组中主线程相同的PID,即该线程组中第一个线程的PID,这与POSIX1003.1c标准里的规定有关,一个多线程应用程序中的所有线程都必须有相同的PID,这样就可以通过PID把信号发送给线程组里所有的线程。Linux中使用了一个比较绕的机制,Linux系统为一个线程组分配一个PID,线程组中的主线程会将这个PID作为自己的PID,同时线程标识符中还有一个成员tgid(Thread Group ID)
,用于标识哪些线程是同一个线程组,此时这个线程组的所有线程都会将这个系统分配的PID作为自己的TGID,然后除主线程外其他线程的PID则通过线程组策略自行商定。这里的特点是这些自行商定的PID是线程组级别的PID,并不是系统级别的PID,不会和系统其他进程的PID冲突。
使用gepid
系统调用会返回当前进程的TGID而不是线程的PID,因为多线程应用程序中的所有线程都共享相同的PID,如果需要获取线程PID则使用系统调用gettid
init_task进程和当前进程
Linux进程中所有进程的task_struct
都通过一个双向链表连接在一起,因此每一个进程的task_struct
中都包含了一个list_head类型的task成员。Linux内核在启动的时候会创建一个init_task
进程,这个进程是所有进程的鼻祖,称为进程0,或者一个我们更加熟悉的名字——idle进程,这个进程也是也是进程链表的起始,后续的进程会以双链表的形式跟在这个初始进程后面。如果某个进程下有一个线程组,那么这些线程也会加入另外一个为线程准备的thread_group
链表中。
next_task()
宏用来遍历下一个进程的task_struct
数据结构,而Linux中提供了for_each_process(p)
宏用来扫描系统中的所有进程,这个宏会从init_task开始扫描,直到再次循环到init)task为止。
#define next_task(p)\list_entry-rcu((p)->tasks.next, struct task_struct,tasks)
#define for_each process (p)\for(p = &init_task; (p = next_task(p))!=&init_task; )
关于当前正在运行的进程,Linux提供了current宏,它可以快速获得当前正在运行的进程的task_struct数据结构。在x86架构中,current宏通过读取当前CPU的percpu
中的变量current_task
来获取当前进程的task_struct指针。而ARM架构中,Linux会让ARM处理器会运行在EL1(也就是内核态)下,sp_el0
寄存器在EL1下不会被使用,因此用于存放当前进程的task_struct指针,current宏通过读取ARM的系统寄存器sp_el0
来获取当前进程的task_struct指针。
进程的创建和终止
最新版本的POSIX标准定义了进程创建和终止的原语,进程创建包括fork()和execve()函数组
写时复制技术
在传统的UNIX操作系统中,创建新进程时会复制父进程拥有的所有资源,但是这样复制是很低效的,每次创建子进程时都要把父进程的进程地址空间的内容复制到子进程,但是子进程还不一定全盘接收,甚至完全不用父进程的资源。子进程执行execve()系统调用之后,完全有可能和父进程分道扬镇。
现代操作系统都采用写时复制技术进行优化。写时复制技术就是父进程在创建子进程时不需要复制进程地址空间的内容给子进程,只需要复制父进程的进程地址空间的页表给子进程,这样父子进程就可以共享相同的物理内存。当父子进程中有一方需要修改某个物理页面的内容时,触发写保护的缺页异常,然后才把共享页面的内容复制出来,从而让父子进程拥有各自的副本。也就是说,进程地址空间以只读的方式共享,当需要写入时才发生复制
创建进程的三个函数
我们可以使用fork, vfork和clone三个函数进行进程创建,其实他们都是通过调用_do_fork
函数进行进程创建的,可以留意这几个函数在实现时调用_do_fork
的参数差别
fork函数
fork原语是POSIX标准中定义的最基本的进程创建函数。
使用fork()函数来创建子进程,子进程和父进程拥有各自独立的进程地址空间,但是共享物理内存资源,包括进程上下文、进程栈、内存信息、打开的文件描述符、进程优先级、根目录、资源限制、控制终端等。在创建期间,子进程和父进程共享物理内存空间,得益于写时复制技术,当它们开始运行各自的程序时,它们的进程地址空间开始分道扬镳。
子进程和父进程有如下一些区别。
- 子进程和父进程的ID不一样。
- 子进程不会继承父进程的内存方面的锁,比如mlock;不会继承定时器、不会继承信号量
SYSCALL_DEFINE0(fork)
{return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
fork函数会返回两次,子进程会返回0;父进程会返回正数,如果返回-1表示创建失败。fork通过系统调用进入linux内核,然后通过_do_fork
函数来实现创建子进程
fork函数只使用SIGCHLD标志位,在子进程终止后发送SIGCHLD信号通知父进程。fork是重量级调用,为子进程建立了一个基于父进程的完整副本,然后子进程基于此运行。为了减少工作量,子进程采用写时复制技术,只复制父进程的页表,不复制页面内容。fork函数也有一些缺点,尽管使用了写时复制技术,但还是需要复制父进程的页表,在某些场景下会比较慢,所以有了后来的vfork原语和clone原语。
vfork()函数
类似于fork,但是vfork的父进程会在子进程存活时一直阻塞,直到子进程调用exit或者execve为止
vfork的实现比fork0多了两个标志位,分别是CLONE_VFORK
和CLONE_VM
。CLONE_VFORK
表示父进程会被挂起,直至子进程释放虚拟内存资源。CLONE_VM
表示父子进程运行在相同的进程地址空间中。vfork的另一个优势是连父进程的页表项复制动作也被省去了。
SYSCALL_DEFINE0(fork)
{return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0);
}
clone函数
clone函数通常用于创建用户线程。但是Linux内核中没有专门的线程,而是将线程作为普通进程来看待。clone函数功能强大,可以传递众多参数,可以有选择地继承父进程的资源,比如可以和vfork一样与父进程共享进程地址空间,从而创建线程;也可以不和父进程共享进程地址空间,甚至可以创建兄弟关系进程。
以glibc封装的clone函数为例,fn
是子进程执行的函数指针;child_stack
用于为子进程分配栈;flags
用于设置clone标志位,表示需要从父进程继承哪些资源;arg
是传递给子进程的参数。
clone函数通过系统调用进入Linux内核,然后通过do_fork
函数来实现。
SYSCALL DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp, int __user *, parent_tidptr,int __user* , child_tidptr,unsigned long,tls)
{return _do_fork(clone_flags,newsp,0,parent_tidptr, child_tidptr, tls);
}
三者对比
fork
- 功能:创建一个新进程,称为子进程。子进程是父进程的副本,包括数据空间、堆和栈的副本(尽管现代实现通常使用写时复制技术来优化内存使用)。子进程拥有独立的进程 ID(PID),并且与父进程并行运行。
- 使用场景:适用于需要完全独立运行的进程,例如在多进程程序中创建子进程来处理不同的任务。
- 优点: 简单易用,子进程完全独立于父进程。 父进程和子进程可以并行运行,互不干扰。
- 缺点:创建新进程的开销较大,尤其是在数据空间较大时。 如果父进程和子进程需要共享数据,需要额外的通信机制(如管道、共享内存等)。
vfork
- 功能:创建一个新进程,但子进程与父进程共享数据空间、堆和栈。子进程运行时,父进程被挂起,直到子进程调用 exec 或退出。子进程对共享内存的修改会直接影响父进程。
- 使用场景:主要用于创建子进程后立即调用 exec 系列函数来加载新程序的场景。适用于需要快速创建子进程且子进程不需要长时间运行的场景。
- 优点:创建子进程的开销较小,因为不需要复制父进程的内存空间。
- 缺点:子进程与父进程共享内存,可能导致数据不一致问题。 父进程在子进程调用 exec 或退出前被挂起,限制了父进程的并发性。
clone
- 功能:创建一个新线程或轻量级进程。也是最灵活的进程创建可以通过参数灵活控制新线程的属性,如栈大小、栈地址、是否共享文件描述符等。
- 使用场景:适用于需要创建多线程程序的场景。用于实现线程库(如 pthread)的底层实现。
- 优点:创建线程的开销比创建进程小得多,因为线程共享进程的内存空间。可以灵活控制线程的属性,适合复杂的多线程程序。
- 缺点:线程共享进程的内存空间,可能导致线程间的数据竞争和同步问题。使用相对复杂,需要通过参数配置线程的属性。
do_fork函数
do_fork是fork\vfork\clone三个系统调用的具体实现,这个函数的定义位于kernel/fork.c
中
<kernel/fork.c>
long _do_fork(unsigned long clone_flags,unsigned long stack_start,
unsigned long stack_size, int __user* parent_tidptr,
int __user* child_tidptr, unsigned long tls)
do_fork函数有6个参数,具体含义如下。
- clone_flags:创建进程的标志位集合。
- stack_start:用户态栈的起始地址。
- stacksize;用户态栈的大小,通常设置为0。
- parent_tidptr:指向用户空间中地址的指针,指向父进程的ID
- child_tidprt:指向用户空间中地址的指针,指向子进程的ID。
- tls:传递的TLS参数。
具体的clone_flags有很多,可以直接打开kimi或者deepseek询问”do_fork函数中clone_flags常见标志位“获取相关信息
进程的终止
进程的终止有两种方式:一种方式是主动终止,包括显式地执行exit
系统调用或者从某个程序的主函数返回:另一种方式是被动终止,在接收到终止的信号或异常时终止。进程的主动终止主要有如下两条途径。
- 从main()函数返回,链接程序会自动添加exit()系统调用。
- 主动执行exit()系统调用。
进程的被动终止主要有如下3条途径。
- 进程收到一个自己不能处理的信号。
- 进程在内核态执行时产生了一个异常。
- 进程收到
SIGKILL
等终止信号。
当一个进程终止时,Linux内核会释放它所占有的资源,并把这条消息告知父进程。一个进程的终止可能有两种情况。
一,它有可能先于父进程终止,这时子进程会变成僵尸进程,直到父进程调用wait()
才算最终消亡。变成僵尸状态的进程,除了进程标识符仍然保留之外,其他的资源都归还给了内核。Linux内核这样做时为了系统和父进程能知道子进程终止原因等信息(这部分信息可能在进程结束后仍需要用到),因此进程终止后的清理工作和进程描述符回收是分开的,当父进程通过wait系统调用获取已终止的子进程信息之后,内核才会释放掉子进程的task_struct
二、它也有可能在父进程之后终止,比如父进程结束的时候,子进程还有事情没有处理完,这时Linux内核会将这一类”孤儿进程“托孤给init
进程(1号进程),init
进程将成为子进程新的父进程。init进程会一直存活,因此init进程的子进程在终止的时候可以继续(一)的流程:变成僵尸进程然后等待init调用wait。
exit()
系统调用会把退出码转换成内核要求的格式,并且调用do_exit()
函数来处理。
SYSCALL-DEFINE1(exit,int,error_code)
{do_exit((error_code&0xff)<<8);
}
进程0和进程1
进程0和进程1是两个特殊的进程。进程0是Linux内核在初始化阶段从无到有创建的第一个进程,是所有进程的祖先,又称为idle进程或sawpper进程。进程0的进程描述符是在init/init_task.c
中被初始化的
<init/init_task.c>struct task_struct init_task
={.state = 0,.stack = init_stack;.active_mm = &init_mm,...
}
初始化函数start_kernel()
在初始化完所有内核所需要的数据结构之后会创建另一个内核线程,这个内核线程就是进程1或者init进程,其PID为1并且和进程0共享所有数据结构。
创建完init进程之后,进程0会执行cpu_idle
函数,又称为闲逛函数,当CPU空闲的时候会执行这个函数,SMP中每个CPU都有一个进程0
进程1会负责执行kernal_init()
函数,初始化用户空间。kernel_init
负责启动用户空间的 init
程序,该程序会启动系统的各种服务和守护进程。init 程序也是所有用户空间进程的起点,它负责管理系统的启动过程,它会根据/etc/inittab
文件内容执行相应的初始化任务,这是一个可以自定义系统初始化用户空间所需执行的动作的文件。
不知不觉已经九千字,但是进程作为Linux中很重要的一环,还没讲完,请期待进程管理下篇,里面会有涉及进程调度、进程切换,多核调度的相关内容。