又是新的一天!在学习过程中感觉对原语两个字的理解还是很晦涩,一度用上了chatGPT,今天最大的收货就是在于对僵尸进程和孤儿进程有了进一步认识,并且知晓了一个新的kill命令:kill -s SIGCHLD 父进程号, 这条命令的作用是通知父进程对子进程执行wait()或waitpid()函数对子进程的PCB资源进行回收,清理僵尸子进程。
此外再度回忆了当年对redis的钻研精神,怀念,当年去老挝出差,带了一本redis的书,上飞机前对redis懵懵懂懂,下飞机后,就对redis充满了信心:)
进程控制原语
什么是进程控制原语?以下是chatGPT的答案:
进程控制原语是操作系统中用于管理和控制进程的基本指令或函数。它们是一组提供了对进程进行创建、终止、阻塞、唤醒等操作的原始操作。进程控制原语通常由操作系统提供,并作为系统调用接口的一部分供应用程序使用。
常见的进程控制原语包括:
- 创建进程(Create Process):用于创建新的进程。通过该原语,应用程序可以请求操作系统创建一个新的进程,并初始化它所需的资源。
- 终止进程(Terminate Process):用于终止正在运行的进程。该原语使操作系统能够停止一个进程的执行并释放其占用的资源。
- 阻塞进程(Block Process):用于将一个进程置于阻塞状态。当进程需要等待某个事件发生时,可以使用该原语将其阻塞,直到事件发生。
- 唤醒进程(Wake up Process):用于唤醒一个被阻塞的进程。当某个进程等待的事件发生时,可以使用该原语将其从阻塞状态唤醒,使其可以继续执行。
- 信号量操作(Semaphore Operations):用于进程间的同步与互斥。通过信号量原语,进程可以申请或释放信号量,实现对临界资源的互斥访问。
这些进程控制原语提供了对进程的基本操作,使得操作系统能够有效地管理和调度多个并发运行的进程,实现进程间的协作和资源的合理利用。
在操作系统中,通常把用于进程控制的程序设计成原语。原语指完成某种特定功能的一段机器指令集合。其重要特征是执行期间不可被打断。原语常驻内存,在内核态执行。用户态可通过接口调用陷入内核态实现。原语的另一个特征是不允许并发执行。如并发执行。进程控制的原语主要包括创建、销毁、阻塞与唤醒。
进程创建
在POSIX标准中,定义了进程创建和终止的操作系统层面的原语。进程创建包括fork()和execve()函数族,进程终止包括wait(), waitpid(), kill()以及exit()函数族。Linux在实现过程中为了提高效率,把POSIX标准的fork()原语扩展为vfork()和clone()两个原语。
进程创建过程
PCB复制
CPU上下文复制
地址空间复制
程序装载
当完成fork()函数通过父进程创建出子进程后,需要执行一个与父进程不同的新程序来完成新的功能,这个实现叫做程序装载。主要实现方法如下
通过exec函数簇的系列函数接口进行传参
可执行文件的寻找与打开
可执行文件的装载
新程序的执行
进程终止
进程终止分成正常结束和异常结束,正常结束的情况下,内核会回收该进程拥有的大部分资源,如关闭进程打开的文件、回收分配个进程的物理内存并解除映射等。这些资源都是属于用户空间的基本资源。剩下进程还占有的内核栈,PCB等资源会由父进程接收到终止进程的状态信息后陷入内核去回收。换句话说,内核只需要将中止进程的相关状态信息给到父进程,然后让终止进程进入僵尸状态,剩下的回收工作将由父进程完成。
主要流程如下:
用户空间资源回收
状态信息发送和僵尸状态的设置
内核资源的回收
为所有子进程寻找新的父进程
重点函数分析
fork()
fork()原语是POSIX标准中定义的基本的进程创建函数。
使用fork()函数创建子进程时,子进程和父进程有各自独立的进程地址空间,但是共享物理内存资源,包括进程上线文、进程堆栈、内存信息、文件描述符、进程优先级等。
在fork(0创建期间,子进程和父进程共享物理内存空间,当他们开始执行各自程序时,进程地址空间开始随着写时复制技术分道扬镳。
fork()函数会有两次返回,一次在父进程中,另一次在子进程中。
如果返回值为0,说明是子进程;如果返回值为正数,说明是父进程,父进程会返回子进程的ID;如果返回-1,标识创建失败。
fork()函数通过系统调用进入Linux内核,然后通过_do_fork()函数实现
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMUstruct kernel_clone_args args = {.exit_signal = SIGCHLD,};return _do_fork(&args);
#else/* can not support in nommu mode */return -EINVAL;
#endif
}
#endif
最后调用copy_process()函数完成。
fork()的缺点在于仍然需要复制父进程的页表项,在某些场景下会比较慢。鄙人碰到最多的就是在2017年-19年期间处理当时redis性能问题遇到的,当redis配置了AOF持久化时,当触发AOF重写机制时,需要fork一个线程bgaofrewrite进行重写,此时fork会对当前redis的内存进行快照,复制redis主进程的页表项, 此时如果redis内存非常大,那么就会涉及到几百MB的内存页拷贝,而此时的操作会产生阻塞,导致后续的redis的查询被block( 前期介绍过redis是单进程单线程的查询操作),因此会立刻被业务感知到,如果处理不当,就会引发生产事件。
vfork()
vfork()函数与fork()函数类似,但是vfork()的父进程会一直阻塞,直到子进程调用exit()或者execve()为止。vfork主要是为写时复制技术出现前fork()函数的缺陷所定义的。
vfork(0通过系统调用进入Linux内核,然后通过_do_fork()函数来实现,相比较fork,vfork在参数中多了clone_vfork和clone_vm两个标志位。clone_vfork表示父进程会被挂起,直至子进程释放虚拟内存资源。clone_vm表示子进程执行在相同的进程地址空间内。此外vfork(0可以避免复制父进程的页表项
SYSCALL_DEFINE0(vfork)
{struct kernel_clone_args args = {.flags = CLONE_VFORK | CLONE_VM,.exit_signal = SIGCHLD,};return _do_fork(&args);
}
clone()
clone()函数通常用于创建用户线程,之前提到在Linux中没有专门的线程,而是把线程当成普通进程处理。
clone()可以传递众多参数,有选择性的继承父进程的资源,与父进程共享一个进程地址空间从而创建线程,也可以不与父进程共享进程地址空间,甚至可以创建兄弟关系进程
重要技术分析
写时复制技术
写入时复制(英语:Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本被创建,因此多个调用者只是读取操作时可以共享同一份资源。
在fork()之后exec()之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。
线程创建及内核线程
前期介绍过,线程是资源调度的最小单位。一个进程创建后对应会创建至少一个线程进行实际的代码执行。
线程可以分成三种: 用户线程、内核线程以及混合线程。
用户线程
开发人员将线程的创建、通讯、同步及销毁等功能封装在线程库内,无需借助系统调用实现,用户线程只存在于用户空间,管理工作均由用户进程完成。内核不会感知到用户线程存在,在内核层面看到的只有一个进程。这种模型有两个优点:
1) 用户线程调度算法和调度过程由用户自行决定,与操作系统内核无关。
2) 用户线程切换不会导致进程的切换,在内核不参与前提下完成线程上下文切换,仅在用户栈和用户寄存中间进行,不涉及CPU状态,系统开销小
弊端也有两个,一是多核处理器中,同一个进程的多个线程在同一个时刻只能有多个线程中的一个获得CPU时间片进行代码执行,无法发挥多核处理器多并发的能力,二是如果一个线程被阻塞时,其他线程也会被阻塞。因为在内核层面认为的这个进程是单进程单线程的程序。内核只会按照单进程单线程的方式去管理及处理。
内核线程
内核线程是由操作系统内核进行管理的,内核想用户进程提供对应的系统调用以支持用户进程的创建、执行、撤销线程。 在这类系统重,用户进程中的线程与内核调度实体是一对一的关系。内核线程是系统调度的最小单位,可以被调度到一个CPU上并发执行,也可以调度到不同CPU上并行处理,如果一个线程被阻塞,操作系统恶意调度该用户进程的其他线程去执行,因为内核层面感知到的进程是有多个线程且和内核线程一一对应。所以不会全部阻塞。
混合型线程
用的比较少,实现难度较高。(Solaris操作系统,已经被基本废弃了), 原理就是利用用户线程和内核线程的优势,线程的创建、同步、通讯及销毁仍然在用户空间完成,同时N个用户级线程被映射到M个内核线程上去使用(N≥M)
僵尸和孤儿进程
如“进程终止”内的描述,当一个进程终止自己的时候,其实它并没有真正的被销毁,内核只是释放了该进程的所有资源,包括打开的文件、占用的内存等,但是留下一个称为僵尸进程的数据结构,这个结构保留了一定的信息(包括进程号 the process ID,退出状态,运行时间),并且会发送给父进程,当父进程通过 wait()/waitpid() 函数进行操作才会释放数据结构,这样设计的目的主要是保证只要父进程想知道子进程结束时的状态信息。于是就会产生两种预期外的产物:
僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵死进程。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作。
如何主动清理僵尸?
1) 在代码编写过程中,涉及到父进程退出必须调用wait()函数绝对变成僵尸进程的子进程进行终止或者在子进程退出时,设置自动向父进程发送 SIGCHILD 信号,父进程处理 SIGCHILD 信号,在信号处理函数中调用 wait 进行处理僵尸进程。
2)找到僵尸进程的父进程,通过kill杀死父进程(kill -s SIGCHLD pid>),根据Linux的机制有init进程来接管这些变成孤儿的僵尸进程,并定期进行释放。
3)当以上两种都不生效,init也无法自动清理僵尸进程时,reboot吧