0%

Linux 进程管理基础知识

进程与线程

对于 Linux 来讲,所有的线程都当作进程来实现,因为没有单独为线程定义特定的调度算法,也没有单独为线程定义特定的数据结构(所有的线程或进程的核心数据结构都是 task_struct)。
对于一个进程,相当于是它含有一个线程,就是它自身。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
进程拥有自己的地址空间,所以每个进程都有自己的页表。而线程却没有,只能和其它线程共享某一个地址空间和同一份页表。这个区别的根本原因是,在进程 / 线程创建时,因是否拷贝当前进程的地址空间还是共享当前进程的地址空间,而使得指定的参数不同而导致的。
进程和线程的创建都可以通过 clone 系统调用完成。clone 系统调用会执行 do_fork 内核函数,而它则又会调用 copy_process 内核函数来完成。主要包括如下操作:在调用 copy_process 的过程中,会创建并拷贝当前进程的 task_stuct,同时还会创建属于子进程的 thread_info 结构以及内核栈。此后,会为创建好的 task_stuct 指定一个新的 pid(在 task_struct 结构体中)。然后根据传递给 clone 的参数标志,来选择拷贝还是共享打开的文件,文件系统信息,信号处理函数,进程地址空间等。这就是进程和线程不一样地方的本质所在。

用户态和内核态

内核态和用户态是操作系统的两种运行级别,用于区分不同程序的不同权利。内核态就是拥有资源多的状态,或者说访问资源多的状态,也称为特权态。相对来说,用户态就是非特权态,访问的而资源将受到限制。如果一个程序运行在特权态,该程序就可以访问计算机的任何资源,它的资源访问权限不受限制。如果一个程序运行在用户态,其资源需求将受到各种限制。如:要访问操作系统的内核数据结构,如进程表,则需要在特权态下才能办到。如果要访问用户程序里的数据,在用户态即可。
用户态切换到内核态的 3 种方式:系统调用、异常、外围设备的中断。其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,可以认为存在前述 3 种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的。

用户栈和内核栈

内核在创建进程的时候,在创建 task_struct 的同时,会为进程创建相应的堆栈。每一个进程都有两个栈,一个用户栈,存在于用户空间;一个内核栈,存在于内核空间。当进程在用户空间运行时,CPU 堆栈指针寄存器里面的内容是用户栈地址,使用用户栈;当进程在内核空间时,CPU 堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。
当进程因为中断或者系统调用陷入到内核态时,进程所使用的堆栈也要从用户栈转到内核栈。进程陷入到内核态后,先把用户态堆栈的地址保存在内核栈之中,然后设置堆栈指针寄存器的内容为内核栈的地址,这样就完成了用户栈向内核栈的转换;当进程从内核态恢复到用户态时,在内核态运行的最后将保存在内核栈里面的用户栈的地址恢复到堆栈指针寄存器即可。这样就实现了用户栈和内核栈的互转。
我们知道从内核态转到用户态时,用户栈的地址是在陷入内核的时候保存在内核栈里面的,但是在陷入内核的时候,如何知道内核栈的地址?其关键点在于进程从用户态转到内核态的时候,进程的内核栈总是空的。这是因为当进程在用户态运行时,使用的用户栈,当进程陷入到内核态时,内核保存进程在内核态运行的相关信息,但是一旦进程返回到用户态后,内核栈中保存的信息已全部弹出,因此每次进程从用户态陷入内核的时候得到的内核栈都是空的。所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器就可以了。

fork, clone

vfork() 是一个过时的优化。在进行良好的内存管理之前,fork() 完整复制了父进程的内存,因此成本很高。 因此在许多情况下,fork() 后面跟着 exec() ,它丢弃复制的内存并创建一个新的内存空间。 现在,fork() 不复制内存;它只是简单地设置为 copy on write ,所以 fork() + exec() 的效率与 vfork() + exec() 一样。
clone() 根据传入参数的不同可用于创建进程和线程。 它们之间的区别仅仅在于哪些数据结构 (内存空间、处理器状态、堆栈、 PID、打开的文件等) 是共享的。
glibc fork() 内部实现调用了 clone()。应用编程一般都调用 clone(),fork() 的存在只是为了兼容老的程序。
execve() 替换当前的进程的可执行镜像为另一个可执行文件。
posix_spawn() 所做的操作和 fork()/execve() 一致,所以推荐使用 posix_spawn() 而不是 fork()/execve()。pthread_create() 创建一个线程,内部也是调用了 clone()。

内核常驻内存

当内核函数申请内存时,内核总是立即满足(因为内核完全信任它们,所以优先级最高)。在分配适当内存空间后,将其映射到内核地址空间中(3-4GB 中的某部分空间),然后将地址映射写入页表。内核地址空间(3-4GB)中的页面所映射的页框始终在物理内存中存在,不会被换出。即使是 vmalloc 动态申请的页面也会一直在物理内存中,直至通过相关内核函数释放掉。其原因在于,一方面内核文件不是太大,完全可以一次性装入物理内存;另一方面在于即使是动态申请内存空间,也能立即得到满足。因此,处于内核态的普通进程或内核线程(后面会提到)不会因为页面没有在内存中而产生缺页异常(不过处于内核态的普通进程会因为页表项没有同步的原因而产生缺页异常)。
每一个普通进程都拥有 4GB 的虚拟地址空间(对于 32 位的 CPU 来说,即 2 B)。主要分为两部分,一部分是用户空间(0-3GB),一部分是内核空间(3-4GB)。每个普通进程都有自己的用户空间,但是内核空间被所有普通进程所共享。

普通线程的用户堆栈与寄存器

对于多线程环境,虽然所有线程都共享同一片虚拟地址空间,但是每个线程都有自己的用户栈空间和寄存器,而用户堆仍然是所有线程共享的。
栈空间的使用是有明确限制的,栈中相邻的任意两条数据在地址上都是连续的。试想,假设多个普通线程函数都在执行递归操作。如果多个线程共有用户栈空间,由于线程是异步执行的,那么某个线程从栈中取出数据时,这条数据就很有可能是其它线程之前压入的,这就导致了冲突。所以,每个线程都应该有自己的用户栈空间。
寄存器也是如此,如果共用寄存器,很可能出现使用混乱的现象。
而堆空间的使用则并没有这样明确的限制,某个线程在申请堆空间时,内核只要从堆空间中分配一块大小合适的空间给线程就行了。所以,多个线程同时执行时不会出现向栈那样产生冲突的情况,因而线程组中的所有线程共享用户堆。
那么在创建线程时,内核是怎样为每个线程分配栈空间的呢?
进程 / 线程的创建主要是由 clone 系统调用完成的。而 clone 系统调用的参数中有一个 void *child_stack,它就是用来指向所创建的进程 / 线程的堆栈指针。而在该进程 / 线程在用户态下是通过调用 pthread_create 库函数而陷入内核的。对于 pthread_create 函数,它则会调用一个名为 pthread_allocate_stack 的函数,专门用来为所创建的线程分配的栈空间(通过 mmap 系统调用)。然后再将这个栈空间的地址传递给 clone 系统调用。这也是为什么线程组中的每个线程都有自己的栈空间。
每个进程或线程都有三个数据结构,分别是 struct thread_info, struct task_struct 和 内核栈,它们都在内核空间中,如下:
image.png

内核线程

内核线程是一种只运行在内核地址空间的线程。所有的内核线程共享内核地址空间(对于 32 位系统来说,就是 3-4GB 的虚拟地址空间),所以也共享同一份内核页表。这也是为什么叫内核线程,而不叫内核进程的原因。
普通进程与内核线程有如下区别:内核线程只运行在内核态,而普通进程既可以运行在内核态,也可以运行在用户态;内核线程只使用 3-4GB (假设为 32 位系统) 的内核地址空间(共享的),但普通进程由于既可以运行在用户态,又可以运行在内核态,因此可以使用 4GB 的虚拟地址空间。
系统中有很多内核守护线程,可以通过:ps -efj 进行查看,其中带有 [] 号的就属于内核守护线程。它们的祖先都是这个 kthreadd 内核线程。

系统调用

linux 内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于内核态,而普通的函数调用由函数库或用户自己提供,运行于用户态。一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求 (或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个:

  • 它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。
  • 系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。
  • 每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在 Linux 中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。

Linux 系统调用的 API 是以 POSIX 标准为基础实现的。传统的系统调用是通过 int 0x80 软中断实现的,但是现代的 CPU 中也提供了如 syscall/sysexit 指令级的支持以加快系统调用执行速度。
系统调用总体上可分为六个类别:

  • 进程控制:创建和终止进程,获取或设置进程属性,等待设定时间,等待事件,信号操作,分配和释放内存;
  • 文件管理:创建、删除、打开、关闭和读写文件,设置文件属性;
  • 设备管理:请求设备,释放设备,读写设备,设置设备属性, attach 或 detach 设备;
  • 信息维护:获取或设置系统时间和日期,获取或设置系统数据;
  • 通信:创建通信连接,发送接收消息,传输状态信息,attach 或 detach 远程设备;
  • 保护:获取或者文件权限。

信号处理机制

如果想要进程捕获某个信号,然后作出相应的处理,就需要注册信号处理函数。同中断类似,内核也为每个进程准备了一个信号向量表,信号向量表中记录着每个信号所对应的处理机制,默认情况下是调用默认处理机制。当进程为某个信号注册了信号处理程序后,发生该信号时,内核就会调用注册的函数。
信号是异步的,一个进程不可能等待信号的到来,也不知道信号会到来,那么,进程是如何发现和接受信号呢?实际上,信号的接收不是由用户进程来完成的,而是由内核代理。当一个进程 P2 向另一个进程 P1 发送信号后,内核接收到信号,并将其放在 P1 的信号队列当中。当 P1 再次陷入内核态时,会检查信号队列,并根据相应的信号调取相应的信号处理函数。

Linux 中的常见信号

Linux 支持标准的 POSIX 信号定义,使用 man 7 signal 可以查看信号的详细信息。每种信号都有预定义的默认行为。常见的几种:当用户退出 shell 时,由该 shell 启动的所有进程将收到 SIGHUP 信号,默认动作为终止进程,SIGHUP 也常用于重启进程,若需要进程忽略该信号,可通过 nohup 启动程序;SIGINT 信号由用户发送 INTR 字符( Ctrl+C )触发;SIGKILL 信号无条件结束程序(不能被捕获、阻塞或忽略),可通过 kill -9 pid 向进程发出信号,强制杀死进程;SIGTERM 信号默认动作也是终止进程,但该信号可以被捕获也可以被忽略,可用于优雅关闭进程,让进程有足够的时间去清理资源,在 Kubernetes 中删除 Pod 时,首先容器中 1 号进程会接收到 SIGTERM 信号开始优雅关闭,超过设定的优雅关闭限时后,进程会收到 SIGKILL 信号强制关闭。

进程调度和进程切换

Linux 既支持普通的分时进程,也支持实时进程。Linux 中的调度是多种混合调度策略和调度算法的混合。Linux 中的调度是基于分时和优先级,且优先级是动态的,会根据进程的行为周期性调整。
Linux 通过 schedule() 函数实现进程调度。schedule () 是内核函数,也不是一个系统调用,所以在用户态时是无法直接调用 schedule () 的,只能间接地调用,通过中断。
进程调度的时机:中断处理过程(时钟中断、I/O 中断、系统调用、异常)中直接调用 schedule (),或者返回用户态时根据 need_resched()标记调用 schedule ()。
用户态进程只能被动调度,内核线程可以直接调用 schedule () 进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。

进程切换与模式切换

当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够根据切换前的状态执行下去。在 Linux 中,当前进程上下文均保存在进程的任务数据结构中。系统调用进行的是模式切换 (mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是进行进程寄存器上下文的切换。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。中断的执行过程类似于系统调用,它复用了当前进程的内核栈,但不代表当前进程运行,而一般是代表硬件运行。

进程 OOM

一般来讲,进程意外终止有两种常见情况:程序本身的 Bug,出现访问非法地址的情况,接收到 SIGSEGV 信号而终止;另一种情况是系统磁盘空间不足或内存空间不足,而内存空间不足又更为常见。内存空间不足时,操作系统为了保护系统本身不致崩溃,会通过 oom killer 选择一些进程将其终止,被选中的进程会接收到 SIGKILL 信号。选择进程时的一个考虑是尽量牺牲最少的进程释放足够的空间,所以一些拥有较多子进程且内存消耗极大的进程(例如 PostgreSQL / Apache / MySQL )会有更大的可能性被选中终止,而系统关键进程(例如具有 CAP_SYS_ADMIN 权限)则基本不会被选中。oom killer 本身并不是常驻的内核线程,而是只有在内存不足时才会触发调用的一组内核函数。
另外需要注意的是,如果使用了 cgroup 对一组进程可用的内存总量进行控制,oom 会在 cgroup 限制的内存被耗尽时针对该组进程触发。

进程间通信机制

匿名管道:只能用于具有亲缘关系的进行通信,使用面相对较窄,实际开发中较少使用;
命名管道:可以用于任意进程间的通信,对于大块数据的传输效率较高,可应用于单进程大量数据传递,和多个进程向一个进程传递数据;
信号:无法传递数据,而且信号的种类有限,只适用于完成一些简单的事件通知任务,如配置跟新信号通知,一个服务通过信号告知另一个服务自身状态;
文件锁:不能用来传递数据,用来对操作进行协调,利用文件锁实现多个进程对于某个资源的排队请求,或者多个进程对系统某个全局资源进行读写操作,可以通过文件锁实现进程间读写锁的功能;
共享文件:文件的存在与否来当锁,文件内容来交互数据;
共享内存:最为高效的进程间通信方式,进程可以直接读写内存,不需要任何数据拷贝,适用于多个进程共享数据,或进程间频繁的进行大量的数据交互,建议使用mmap方式;
消息队列:进程间传递简单的命令和控制消息,如配置更新通知,多进程对多进程的通信等,可以简化代码逻辑,建议使用全双工管道替代;
信号量:某种资源数为N,多个进程都在使用该资源,为了进行进程间的互斥,可以使用初始值为N的信号量,–建议使用记录锁替代;
Unix 套接字:某个服务与多个服务同时通信,此时需要维护多个通信通道,使用 Unix 套接字,可以使用 Linux IO 多路复用功能,建议优先考虑网络套接字;
网络套接字:如果系统需要支持分布式部署,服务可能在同一设备或者不同设备,此时使用网络套接字比较合适,提高了扩展性。

进程间锁

进程较线程来说相对独立,进程间同步的粒度一般较大,且多借助分布式消息队列、分布式内存等中间件实现分布式节点之间的同步。单机上多进程间同步相对少见,一种典型的场景是 Nginx 多进程模型中的 woker 进程通过对 accept 加锁实现多 worker 进程之间的协调。进程间锁借助进程间通信机制实现,是进程间通信机制的一种应用实例。进程间锁可借助共享内存、共享文件、信号量等方式实现。借助共享内存实现进程间锁的基本原理是:开辟一块共享内存,使得相关进程均可访问同一块区域,再将互斥锁定义在该区域(即共享内存)上,使得相关进程可以使用该锁。为解决多进程对同一文件的读写冲突,在linux 系统中,提供了 flock 这一系统调用,用来实现对文件的读写保护,即文件锁的功能。文件锁保护文件的功能,与 fork 多进程及 pthread 库中多线程使用读写锁来保护内存资源的方式是类似的。

线程同步机制

线程间因为共享资源易产生资源竞争所以需要进行同步。常用的线程同步机制有:互斥锁、读写锁、条件变量等。

nice 值

在 Linux 中,nice 值表示进程的优先级,一般地 nice 值的范围从 -20 到 +19(不同系统的值范围是不一样的),正值表示低优先级,负值表示高优先级,值为零则表示不会调整该进程的优先级。具有最高优先级的程序,其 nice 值最低,所以在 Linux 系统中,值 -20 使得一项任务变得非常重要;与之相反,如果任务的 nice 为 +19 ,则表示它是一个高尚的、无私的任务,允许所有其他任务比自己享有宝贵的 CPU 时间的更大使用份额,这也就是 nice 的名称的来意。默认优先级是 0 。

1
2
3
4
# 运行程序时可指定 nice 值
nice -n -5 vim a.txt
# 使用 renice 修改正在运行进程的优先级
renice -n 6 23305

CPU 亲和性

在多核的情况下,可以使用 taskset 命令指定一个进程在哪颗 CPU 上执行程序,减少进程在不同 CPU 之间切换的开销。

1
2
3
4
5
6
# 使用 top 命令,按下数字 1,可以看到 CPU 各个核心的情况
# 假设 CPU 有四个核心,则其编号一般是 0,1,2,3
# 以下命令指定程序运行在第三个核心上
taskset -c 0,2 vim a.txt
# 以下命令可以查看指定进程的 CPU 亲和列表,若未指定 CPU 亲和性,则结果中默认会列出所有的 CPU 核心
taskset -cp 1316

Nginx 使用多进程而不是多线程

Nginx 要保证它的高可用高可靠性,若使用多线程,由于线程之间是共享同一个地址空间的,当某一个第三方模块引发了一个地址空间的段错误时(例如:地址越界),会导致整个 Nginx 全部挂掉;当采用多进程来实现时,往往不会出现这个问题。

进程状态

image.png

subreaper 进程

当用户态通过 prctl(PR_SET_CHILD_SUBREAPER,1) 的时候就是让当前进程像 init 进程一样来收养孤儿进程,称为 subreaper 进程。

参考资料

本文到此结束  感谢您的阅读