写点什么

浅谈 Linux 进程模型

作者:lecury
  • 2021 年 12 月 04 日
  • 本文字数:7688 字

    阅读完需:约 25 分钟

浅谈Linux进程模型

本文首次发布于知乎专栏:https://www.zhihu.com/column/c_1068890727731240960

1. 进程基础

1.1 基础概念

进程是操作系统的基本概念之一,它是操作系统分配资源的基本单位,也是程序执行过程的实体。程序是代码和数据的集合,本身是一个静态的概念,而进程是程序的一次执行的实体,是一个动态的概念。

那在 Linux 操作系统中,是如何描述一个进程的呢?

1.2 进程描述符

为了管理进程,内核需要对每个进程的属性和所需要做的事情,进行清楚的描述,这个就是进程描述符的作用,Linux 中的进程描述符由task_struct标识。

task_struct的数据结构是相当复杂的,不仅包含了很进程属性的字段,而且也包括了指向其他数据结构的指针。大致结构如下:

  • state: 描述进程状态

  • thread_info: 进程的基本信息

  • mm: mm_struct指向内存区描述符的指针

  • tty: tty_struct终端相关的描述符

  • fs: fs_struct当前目录

  • files: files_struct指向文件描述符的指针

  • signal: signal_struct所接收的信号描述

  • 很多等等。。

总结一下,进程描述符完整的保存了一个进程的属性和生命周期内的数据、状态和行为,由一个复杂的数据结构task_struct来表示。

1.3 进程创建

Linux 创建一个进程,大致经历的过程如下:

  • 初始化进程描述符

  • 申请相应的内存区域

  • 设置进程状态、加入调度队列等等

  • ...

为了完整的描述一个进程,操作系统设计了非常复杂的数据结构、也申请了大量的内存空间。但是得益于写时复制技术,这些初始化操作,并没有明显的降低进程的创建速度。

写时复制技术:当新进程(子进程)被创建时,Linux 内核并不会立马将父进程的内容复制给子进程,而仅仅当进程空间的内容发生变化时,才执行复制操作。写时复制技术允许父子进程读取相同的物理页,只要两者有一个试图更改页内容,内核就会把这个页的内容拷贝到新的物理页,并把这块页分给正在写的进程。

Linux 中有三种系统调用可以创建进程 clone()、fork()、vfork()

  • clone(): 最基础的创建进程的系统调用,可以指明子进程的基础属性(由各种 FLAG 标识)、堆栈等等。

  • fork(): 通过 clone()实现,它的堆栈指向的是父进程的堆栈,因此父子进程共享同一个用户态堆栈。fork 的子进程需要完全 copy 父进程的内存空间,但是得益于写时复制技术,这个过程其实挺快。

  • vfork(): 也是基于 clone()来实现的,是历史上对 fork()的优化,因为 fork()需要 copy 父进程的内存空间,并且 fork()后常常执行 execve()将另一个程序加载进来,在写时复制技术之前,这种不必要的 copy 是代价是比较高昂的。因此 vfork()实现时,会指明 flag 告诉 clone()共享父进程的虚拟内存空间,以加快进程的创建过程。

1.4 上下文切换

概念:进程创建好之后,内核必须有能力挂起正在 CPU 运行的进程,并切换其他进程到 CPU 上执行。这种过程被称作为进程切换、任务切换或者上下文切换。

这个过程包括硬件上下文切换和软件上下文切换。

硬件上下文切换:主要通过汇编指令 far jmp 操作,将一个进程的描述符指针,替换为另一个进程描述符指针,并改变 eip、cs、esp 等寄存器,从而改变程序的执行流。当进程被切换出去时,Linux 进程描述符中的 thread 字段,就会保存该进程的硬件上下文。thread 数据结构,包含了大部分 CPU 寄存器。

软件上下文切换:

  • 内存地址的切换,切换页全局目录,安装新的地址空间。

  • 内核态堆栈的切换。

进程切换发生在schedule()函数中,有兴趣的同学,可以详细了解下 switch_to 宏的实现。

内核需要知道什么时候调用 schedule,如果靠用户代码显式的调用,那么它们可能一直永远执行下去。内核提供了一个 need_resched 的标志,来表明是否需要重新执行一次调度。当某个进程被抢占或者更高优先级的进程进入可执行状态时,内核都会设置这个标志。那什么时候,内核会检查这个标志,来重新调度程序呢?那就是从内核态切换成用户态,或者从中断返回时。

执行系统调用时,会经历用户态与内核态的切换以及中断返回。也就是说,每一次执行系统调用,比如 fork、read、write 等,都可能触发内核调度新进程。

1.5 init 进程

Linux 进程是以树形的结构组织的,每一个进程都有唯一的进程标识,简称 PID,同时每个进程可以通过 PID,找到它们的父进程。

Linux 系统启动之初,会创建几个特殊的进程,它们的进程 PID 为 0、1 等等。

  • 0:swapper 或者 scheduler 进程,实际上是内核的一部分,运行在内核态。

  • 1:常被称作 init 进程,负责系统的初始化,是其他进程的祖先进程。

每个进程可以递归的访问父进程,以访问到 init 进程,如果 init 跪了,整个系统就跪了。由于 init 进程的重要地位,它相对于普通进程来说,有三个特殊之处:

  • 它没有默认的信号处理,因此如果发信号给 init 进程的话,会被它忽略掉,除非显示的注册过该信号。如果熟悉 docker 的同学,会观察到 docker 化的进程,如果按 ctrl-c 是没啥反应的,因为 docker 化的进程它们有独立的 pid 命名空间,第一个新创出的进程,pid 为 1,是不会理会 kill signal 信号的。

  • 如果一个进程退出时,它还有子进程存在,被称为孤儿进程,那么这些孤儿进程会重新成为 init 进程的子进程,转由 init 进程来管理这些子进程,包括回收退出状态、从进程表中移除等。

  • 如果 init 进程跪了,那么所有用户进程都会被退出。

与孤儿进程类似的是僵尸进程,这个是由于子进程退出,但是父进程没有调用 wait/waitpid 回收子进程的结束状态,导致子进程的描述符仍然保留在系统中,占据系统的资源,这种进程被称为僵尸进程。

清理僵尸进程的方法,是杀掉不断产生僵尸进程的父进程,然后这些僵尸进程会称为孤儿进程,由 init 进程接管、回收。

2. 进程应用

上面提到了进程的一些基础信息,包括概念、进程描述符、上下文切换,并介绍了 init 进程的特殊之处。接下来从应用层面,介绍一下 Linux 进程。

2.1 进程间通信

谈到通信我们都知道,通信的双方必须存在一种可以承载信息的介质,对于计算机之间的通信来说,这种介质可以是双绞线、光纤、电磁波。那对于进程间的通信呢?这种介质有哪些呢?

进程是操作系统分配资源的基本单位,进程的很多资源是相互隔离的,如果想要完成通信过程,就需要中间介质,这种介质需要独立于进程之外,并可以被多个进程都能可以访问。在 Linux 中,满足这种条件的介质,可以是:

  • 操作系统提供的内存介质,比如共享内存、管道、信号量等。

  • 文件系统提供的文件介质,比如 UNIX 域套接字、文件等

  • 网络设备提供的网卡介质,比如 socket 套接字。

  • 等等。

对于操作系统提供的介质来说,常用的有

  • 信号量机制

  • 匿名管道(仅限父子进程)与有名管道

  • SysV 和 POSIX 消息队列共享内存

  • 等等

优缺点介绍:

  • 信号量:不能传递复杂消息,只能用来同步

  • 匿名管道:容量有限速度较慢,只有父子进程能通讯

  • 有名管道:任何进程间都能通讯,但速度较慢。

  • 消息队列:容量受到系统限制,有队列的特性,先进先出。

  • 共享内存:速度快,可以控制容量大小,但需要进行同步操作。

它们的用法相对较为简单,在需要使用时查阅相关文档即可,共享内存是比较常用的做法。

2.2 信号处理

信号最早是在 Unix 系统被引入,它主要用于进程间的通信,同时进程可以主动注册信号处理函数,来检测或者应对系统发生的事件。比如当进程访问非法地址空间时,进程会收到操作系统发送 SIGSEGV 信号,默认情况下的处理方式是:该进程会退出并且把堆栈 dump 出来,简称出 core。

总的来说信号的主要目的:

  • 让进程知道已经发生的特定事件。

  • 强迫进程处理这个特定事件。

目前 Linux 支持的信号,已经默认的处理函数,可以在 man 手册中查到,截图如下:



对于信号,我们需要知晓它的编号、名称、事件和默认的处理函数。比较常见的信号,解释如下:

  • SIGCHLD: 一个进程通过 fork 函数创建,当它结束时,会向父进程发送 SIGCHLD 信号。

  • SIGHUP: 挂起信号,当检测到控制终端,或者控制进程死亡时。比如用户退出 shell 终端时,该 shell 启动的所有进程,都会收到这个信号,默认是终止进程。

  • SIGINT:当用户按下 Ctrl+C 组合键时,终端会向该进程发送此信号,默认是终止进程。

  • SIGKILL: 常用的 kill -9 指令会发送该信号,无条件终止进程,本信号无法被忽略。

  • SIGSEGV: 进程访问了非法的内存地址,默认行为是终止进程并产生 core 堆栈。

  • SIGTERM: 程序结束信号,该信号可以被阻塞和忽略,通常标识程序正常退出。

  • SIGSTOP: 停止进程的执行,该信号不能被忽略,默认动作为暂停进程。

  • SIGPIPE: 当往一个写端关闭的管道或 socket 连接中连续写入数据时会引发 SIGPIPE 信号,引发 SIGPIPE 信号的写操作将设置 errno 为 EPIPE。在 TCP 通信中,当通信的双方中的一方 close 一个连接时,若另一方接着发数据,根据 TCP 协议的规定,会收到一个 RST 响应报文,若再往这个服务器发送数据时,系统会发出一个 SIGPIPE 信号给进程,告诉进程这个连接已经断开了,不能再写入数据。

其实在项目开发中,常常会和信号处理打交道。比如在处理程序优雅退出时,一般需要捕获 SIGINT、SIGPIPE、SIGTERM 等信号,以合理的释放资源、处理剩余链接等,防止程序意外 crash,导致的一些问题。

2.3 后台进程与守护进程

在接触 Linux 系统时,常常会遇到后台进程与守护进程,这里简单的介绍一下这两种进程。

  • 后台进程:通常情况下,进程是放置在前台执行,并占据当前 shell,在进程结束前,用户无法再通过 shell 做其他操作。对于那些没有交互的进程,可以将其放在后台启动,也就是启动时加一个 &,那么在该进程运行期间,我们仍是可以通过 shell 操作其他命令。不过当 shell 退出时,该后台进程也会退出

  • 守护进程:如果一个进程总是以后台的方式启动,并且不能受 shell 退出的影响而退出,那么可以将其改造为守护进程。后续进程是系统长期运行的后台进程,比如 mysqld、nginx 等常见的服务进程。

那么这两者有啥区别呢?

  • 守护进程已经完全脱离终端,而后台进程并未完全脱离终端,即后台进程仍是可以输出到终端的。

  • 在终端关闭时,后台进程会收到信号退出,但是守护进程则不会。

举个例子,通过./spider &在后台执行抓取任务,但没过多久,终端自动断开,导致 spider 进程中断退出。

在进一步了解守护进程之前,还需要了解一些会话和进程组的概念。

  • 进程组:由一系列相互关联的进程组成,由 PGID 来标识,一般是进程组创建进程的 PID。进程组的存在是为了方便对多个相关进程执行统一的操作,比如发送信号量给统一进程组的所有进程。

  • 会话:由若干个进程组组成,每一个进程组从属于一个会话,一个会话对应着一个控制终端,该终端为会话所有进程组的进程所共用,其中只有前台进程组才可以与终端交互。

那如何实现一个守护进程呢?

  1. 在后台运行:fork 出子进程 A,当前进程退出,保留子进程 A。

  2. 脱离控制终端:目的是摆脱终端的影响,通过setsid()重新为子进程 A 设置新的会话。

  3. 禁止子进程 A 重新打开终端:因为设置新会话之后的进程 A,是进程组的组长,所以它是有能力重新申请打开一个控制终端。通过再次 fork 子进程 B,并退出进程 A,B 不再是进程组组长,也无法打开新的终端。

  4. 关闭已打开的文件描述符、改变工作目录等等。

  5. 处理 SIGCHILD 信号:由于守护进程一般是长期运行的进程,当产生子进程时,需要处理子进程退出时发送的 SIGCHILD 信号,不然子进程就会变成僵尸进程,从而占据系统资源。

总结来说,守护进程是一种长期运行于后台的进程,它脱离了控制终端,不受用户终端退出的影响。可以通过nohup操作,将一个进程变成守护进程执行。比如nohup ./spider &,这样即使终端断开后,spider 进程仍会继续执行。

2.4 浅谈 nginx 多进程模型

nginx 是一款高性能的 Web 服务器,由于它优秀的性能、成熟的社区、完善的文档,受到广大开发者的喜爱和支持。它的高性能与其架构是分不开的,nginx 的框架如下图所示:


Nginx 是经典的多进程模型,它启动以后以守护进程的方式在后台运行,后台进程包含一个 master 进程,和多个 worker 进程。其中 master 进程相当于控制进程,有以下作用:

  • 接收外界信号执行指令,包括配置加载、向 worker 发指令、优雅退出等等。

  • 维护 worker 进程的状态,当 worker 进程退出后,自动启动新的 worker。

其中 master 进程支持的信号处理如下:

  • TERM、INT:快速退出

  • QUIT:优雅退出

  • HUP: 变更配置,用新配置启动 worker,优雅关闭老的 worker 等。

  • USR1: 重新打开日志文件

  • USR2: 升级二进制文件(nginx 升级)

  • WINCH: worker 进程的优雅退出

单个 worker 进程也支持信号处理,包括:

  • TERM、INT: 快速退出

  • QUIT: 优雅退出

  • USR1: 重新打开日志文件

  • WINCH: 终端调试等

worker 进程基于异步非阻塞的模式处理每个请求,这种非阻塞的模式,大大提高了 worker 进程处理请求的速度。为了尽可能的提高性能,nginx 对每个 worker 进程设置了 CPU 的亲和性,尽量把 worker 进程绑定在指定的 CPU 上执行,以减少上下文切换带来的开销。由于这种绑核的模式,一般推荐 worker 进程的数目,为 CPU 的核数。

nginx 使用了 master<->worker 这种多进程的模型,有哪些好处呢?

  • worker 进程间很少共享资源,在处理各自请求时,几乎不用加锁,省掉了锁带来的开销。

  • worker 进程间异常不会相互影响,一个进程挂掉之后,其他进程还在工作,可以提高服务的稳定性。

  • 尽可能的利用多核特性,最大化利用系统资源。

3. 常用工具介绍

Linux 内置了许多工具,用于排查系统问题和查看资源使用情况,这里简单介绍和进程有关的几个工具。

3.1 ps: 查看进程的基本属性

常用的参数如下:

  • ps aux : 查看所有进程的基本信息

  • ps -p $pid : 查看指定 pid 的进程

  • ps -fp $pid : 打印的进程信息较全

  • 自定义打印进程的信息:例如ps -C nginx -o pid,ppid,rsz,vsz,pcpu: 打印 nginx 进程的 pid、ppid、实存、虚存、cpu。

  • ps axjf:查看进程树:,使用pstree -p $pid更加直观

  • ps -T -p $pid或者ps -Lf $pid: 查看进程的线程信息

  • 等等

除了通过 ps 获取进程的信息外,还可以通过/proc 文件系统来查看进程的基本信息:

  • /proc/$pid/cmdline: 进程的命令行参数

  • /proc/$pid/cwd: 当前工作目录

  • /proc/$pid/environ: 环境变量值

  • /proc/$pid/exe: 软链到二进制执行程序。

  • /proc/$pid/fd: 包含所有的文件描述符。

  • /proc/$pid/maps: 内存映射,包括二进制和 lib 文件。

  • /proc/$pid/mem: 进程的内存

  • /proc/$pid/stat: 进程状态

  • 等等

3.2 lsof: 查看进程打开的文件情况

有两个场景:

  • 场景一:机器上一个文件大小不停的增长,导致磁盘空间一次又一次的爆满,如果这时候你想把写文件的罪魁祸首进程找到,那应该怎么做呢?

  • 场景二:发现磁盘已经快满了,通过 rm -f 删除一些大文件,但磁盘空间并没有明显减少,这个时候应该怎么做呢?

对于这些场景,我们可以借助 lsof 命令,

  • 对于场景一来说:可以查看该文件被哪个进程打开,找到罪魁祸首进程,然后对其处理。

  • 对于场景二来说:如果这个文件被其他进程打开,通过 rm -f 是无法真正删掉一个文件的,还需要杀掉打开该文件的进程,以关闭文件描述符,那么文件才能真正被清理。

lsof 的常见用法如下:

  • 查看特定用户打开的文件列表:lsof -u xxx

  • 查看特定端口打开的文件列表:lsof -i 8080

  • 查看特定端口范围打开的文件列表:lsof -i :1-1024

  • 基于 TCP 或者 UDP 查看打开的文件列表:lsof -i udp

  • 查看特定进程打开的文件列表:lsof -p $pid

  • 查看打开特定文件的进程列表:lsof -t $file_name

  • 查看打开特定目录的进程列表:lsof +D $file_path

  • 等等

3.3 netstat: 查看网络连接情况

netstat 是一个监控 TCP/IP 网络非常有用的工具,它可以显示路由表、网络连接、网络接口设备状态等信息。输出的信息类型由第一个参数决定:

  • (none): 默认情况下,netstat 会显示打开的 socket 列表。

  • --route,-r: 显示内核的路由表,和 -e 的输出相同。

  • --group,-g: 显示 IPv4 和 IPv6 的多播组成员身份信息。

  • --interfaces, -i: 显示网络接口状态。

  • --statistics, -s: 显示每一种协议的统计信息。

下面列举各个场景的使用用法:

  • 仅显示数字地址:netstat -n

  • 仅显示 tcp 链接:netstat -t

  • 仅显示 udp 链接:netstat -u

  • 仅显示监控 socket 链接:netstat -l

  • 显示进程的名字和 PID:netstat -p

3.4 strace: 查看系统调用情况

strace 用来跟踪进程执行时的系统调用和所接收的信号。在 Linux 中,进程是无法直接访问硬件设备的,当访问硬件设备时,必须要切换至内核态模式,通过系统调用来访问硬件设备。

strace 可以跟踪到一个进程产生的系统调用,包括参数、返回值、执行消耗的时间。每一行的输出,左边是系统调用的函数名和参数,后面是调用的返回值。用法如下:

-c 统计每一系统调用的所执行的时间,次数和出错的次数等.-d 输出strace关于标准错误的调试信息.-f 跟踪由fork调用所产生的子进程.-ff 如果提供-o filename,则所有进程的跟踪结果输出到相应的filename.pid中,pid是各进程的进程号.-F 尝试跟踪vfork调用.在-f时,vfork不被跟踪.-h 输出简要的帮助信息.-i 输出系统调用的入口指针.-q 禁止输出关于脱离的消息.-r 打印出相对时间关于,,每一个系统调用.-t 在输出中的每一行前加上时间信息.-tt 在输出中的每一行前加上时间信息,微秒级.-ttt 微秒级输出,以秒了表示时间.-T 显示每一调用所耗的时间.-v 输出所有的系统调用.一些调用关于环境变量,状态,输入输出等调用由于使用频繁,默认不输出.-V 输出strace的版本信息.-x 以十六进制形式输出非标准字符串-xx 所有字符串以十六进制形式输出.-a column设置返回值的输出位置.默认 为40.-e expr指定一个表达式,用来控制如何跟踪.格式如下:[qualifier=][!]value1[,value2]...qualifier只能是 trace,abbrev,verbose,raw,signal,read,write其中之一.value是用来限定的符号或数字.默认的 qualifier是 trace.感叹号是否定符号.例如:-eopen等价于 -e trace=open,表示只跟踪open调用.而-etrace!=open表示跟踪除了open以外的其他调用.有两个特殊的符号 all 和 none.注意有些shell使用!来执行历史记录里的命令,所以要使用\\.-e trace=set只跟踪指定的系统 调用.例如:-e trace=open,close,rean,write表示只跟踪这四个系统调用.默认的为set=all.-e trace=file只跟踪有关文件操作的系统调用.-e trace=process只跟踪有关进程控制的系统调用.-e trace=network跟踪与网络有关的所有系统调用.-e strace=signal跟踪所有与系统信号有关的 系统调用-e trace=ipc跟踪所有与进程通讯有关的系统调用-e abbrev=set设定 strace输出的系统调用的结果集.-v 等与 abbrev=none.默认为abbrev=all.-e raw=set将指 定的系统调用的参数以十六进制显示.-e signal=set指定跟踪的系统信号.默认为all.如 signal=!SIGIO(或者signal=!io),表示不跟踪SIGIO信号.-e read=set输出从指定文件中读出 的数据.例如:-e read=3,5-e write=set输出写入到指定文件中的数据.-o filename将strace的输出写入文件filename-p pid跟踪指定的进程pid.-s strsize指定输出的字符串的最大长度.默认为32.文件名一直全部输出.-u username以username 的UID和GID执行被跟踪的命令
复制代码

当服务器卡顿时,可以通过 strace 系统调用查看特定进程的系统调用执行情况:

strace -c -tt -o ./server.log -p 26844
复制代码

输出如下:

% time     seconds  usecs/call     calls    errors syscall------ ----------- ----------- --------- --------- ----------------100.00    0.170843        2512        68           epoll_wait------ ----------- ----------- --------- --------- ----------------100.00    0.170843                    68           total
复制代码


本文首次发布于知乎专栏:https://www.zhihu.com/column/c_1068890727731240960

发布于: 1 小时前阅读数: 10
用户头像

lecury

关注

还未添加个人签名 2018.04.27 加入

还未添加个人简介

评论

发布
暂无评论
浅谈Linux进程模型