【回炉】理解Unix进程-1

  |  

摘要: 《理解Unix进程》回炉笔记,第一部分

【对算法,数学,计算机感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:算法题刷刷
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


本书信息


写在前面

本文回炉一本之前看过的书《理解Unix进程》,本书从Unix编程的基础概念着手,采用循序渐进的方法介绍了Unix进程的内部工作原理。但是写的比较浅,都是一些应用级别的东西,没有从内核里分析进程及进程间通信的实现方式。

本书可以理解为《APUE》进程部分的简化版,偏重概念性的介绍,如果不想读《Linux/UNIX系统编程手册》或《UNIX环境高级编程》这种大部头,读读这种小书扫扫盲也不错,虽然浅但是比较全面。主要内容如下:

1 基础知识
2 进程皆有标识
3 进程皆有父
4 进程皆有文件描述符
5 进程皆有资源限制
6 进程皆有环境
7 进程皆有参数
8 进程皆有名
9 进程皆有退出码
10 进程皆可衍生
11 孤儿进程
12 写时复制
13 进程等待
14 僵尸进程
15 进程皆可获得信号
16 进程皆可互通
17 守护进程
18 生成终端进程
19 总结

本文是回炉的第一部分,内容包括上述目录的第 1 ~ 13 部分。


1. 基础知识

系统调用

程序不可以直接访问内核,所有通信都是通过系统调用来完成。

系统调用是C编程的核心。

系统手册页

学习 Unix 编程的障碍之一就是不知道从哪里查找合适的文档。其实所有东西都包含在 Unix 手册页(manpages)中了。

在 Unix 下用 man 查询手册页时,往往有一个 section。例如 man select 返回的手册页如下。其中 SELECT(2) 这里的 (2) 就表示 select 所属的 select。

1
2
3
SELECT(2)            Linux Programmer's Manual            SELECT(2)

...

FreeBSD和Linux系统手册页常用的section如下:

(1) 一般命令
(2) 系统调用
(3) C库函数
(4) 特殊文件

2. 进程皆有标识

os.getpid() 对应于 getpid(2)。这里的 (2) 表示 getpid 为系统调用。

3. 进程皆有父

ppid在实际操作中没有太多用处,但在检测守护进程时却发挥着重要作用。

os.getppid() 对应着 getppid(2)。

4. 进程皆有文件描述符

Unix 哲学指出,在 Unix 世界中,万物皆为文件。这意味着可以将设备视为文件,将套接字和管道视为文件,将文件也视为文件。

  • 一般意义上的文件(包括设备,管道,套接字等) —> 资源
  • 传统意义的文件(文件系统中的文件) —> 文件

描述符代表资源

1
2
3
4
5
f = open("/etc/passwd")
f.fileno()
f.close()
f.fileno()
# 报 ValueError: I/O operation on closed file

从以上例子可以看出:

  • 所分配的文件描述符编号是尚未使用的最小的数值
  • 试图获取已关闭资源的文件描述符会产生一个异常

每个Unix进程都有3个打开的资源,它们是标准输入,标准输出,标准错误

1
2
3
sys.stdin.fileno()
sys.stdout.fileno()
sys.stderr.fileno()

Python 的 os 中的不少方法都对应着同名的系统调用。例如

1
2
3
4
5
6
7
open(2)
close(2)
read(2)
write(2)
pipe(2)
fsync(2)
stat(2)

5. 进程皆有资源限制

内核为进程施加了某些资源限制

resource.getrlimit 和 resource.setrlimit 分别对应于系统调用getrlimit(2)及 setrlimit(2)。

使用 Python 来直接查询所允许的最大的文件描述符编号:

1
2
import resource
resource.getrlimit(resource.RLIMIT_NOFILE)

我的机器返回值如下:

1
(1024, 4096)

数组的第一个元素是文件描述符数量的软限制(soft limit),第二个元素是文件描述符数量的硬限制(hard limit)

软限制其实算不上一种限制。也就是说如果超出了软限制(在这里指一次打开了超过 1024 个资源), 将会产生异常。不过只要你愿意,就可以修改这个限制。

软限制和硬限制都可以用 Python 标准库 resource 中的组件修改, 但注意改应限制需要 root 权限。

超出了软限制,则会抛出 OSError 异常。

1
2
3
4
5
6
# 将可以打开的最大文件数设置为 3。我们知道这会超出最大限制,
# 因为标准流已经占用了前 3 个文件描述符。
rlimit_nofile = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (3, rlimit_nofile[1]))
f = open("./file.csv")
print("open success")

报错信息如下:

1
2
3
Traceback (most recent call last):
File "test.py", line 5, in <module>
OSError: [Errno 24] Too many open files: './file.csv'

resource 的 getrlimit 还可以获取其它方面的资源的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import resource
LIMITS = [
('RLIMIT_CORE', 'core file size'),
('RLIMIT_CPU', 'CPU time'),
('RLIMIT_FSIZE', 'file size'),
('RLIMIT_DATA', 'heap size'),
('RLIMIT_STACK', 'stack size'),
('RLIMIT_RSS', 'resident set size'),
('RLIMIT_NPROC', 'number of processes'),
('RLIMIT_NOFILE', 'number of open files'),
('RLIMIT_MEMLOCK', 'lockable memory address'),
]
print('Resource limits (soft/hard):')
for name, desc in LIMITS:
limit_num = getattr(resource, name)
soft, hard = resource.getrlimit(limit_num)
print('{:<23} {}/{}'.format(desc, soft, hard))
1
2
3
4
5
6
7
8
9
core file size          0/-1                # 可以创建的最大的文件。
CPU time -1/-1
file size -1/-1
heap size -1/-1
stack size 8388608/-1 # 用于进程栈的最大段的大小
resident set size -1/-1
number of processes 39694/39694 # 当前用户所允许的最大并发进程数。
number of open files 1024/4096
lockable memory address 67108864/67108864

需要修改系统资源限制的一些实践场合

  • 其中一种情况就是某个进程需要处理成千上万的并发网络连接。例如 http 性能测试工具 httperfhttperf --hog --server www --num-conn 5000 这样的命令会使得 httperf 创建 5000 个并发连接。因此在进行正确的测试之前, httperf 需要调高对应的软限制。

  • 另一个需要进行系统资源限制的实际例子是,在执行第三方代码并需要对其施加一些运行限制时。你可以对代码所属的进程设置限制,并取消修改这些限制的权限,这样就能确保其无法使用超出许可范围的资源数量。

6. 进程皆有环境

  • 这里所说的环境,指的是“环境变量”。
  • 环境变量是包含进程数据的键-值对(key-value pairs)。

所有进程都从其父进程处继承环境变量。它们由父进程设置并被子进程所继承。每一个进程都有环境变量,环境变量对于特定进程而言是全局性的。

在 Python 中设置一个环境变量。

1
os.environ.setdefault(k, v)

环境变量能够用于在执行不同语言(例如 shell 和 Python)的进程之间共享状态。

环境变量经常作为一种将输入传递到命令行程序中的通用方法。比起解析命令行选项,使用环境变量的开销通常更小一些。

系统调用不能直接操作环境变量,不过 C 库函数 setenv(3)和 getenv(3) 却可以完成这样的工作。

7. 进程皆有参数

所有进程都可以访问名为 argv 的特殊数组。其他编程语言可能在实现方式上略微不同,但是都会有 argv。

1
2
3
import sys
for x in sys.argv:
print(x)

这里的 argv 只是一个Array 。可以随意地从中添加、删除和更改元素。但如果它仅仅代表的是从命令行传入的参数,那就没有必要修改它。

有些库会读取 argv 来解析命令行选项。你可以在这些库读取 argv 之前,通过编程的方式对其进行修改,以在运行时更改命令选项。

实践场景1:

argv 最常见的例子大概就是将文件名传入程序。

实践场景2:

解析命令行输入。有很多 Python 库可以用来处理命令行输入。在标准库中就有一个叫做 argparse 的库。

8. 进程皆有名

Unix 进程几乎没有什么固有的方法来获悉彼此的状态。

程序员们对此已经找到了解决之道,并发明了像日志文件这样的方法。日志文件使得进程可以通过向文件系统写入信息的方式来了解彼此的状态信息,不过这种操作属于文件系统层面,而非进程本身所固有的。

与此类似,进程可以借助网络来打开套接字同其他进程进行通信。但是这也并非运作在进程层面,因为它是依靠网络来实现的。

有两种运作在进程自身层面上的机制可以用来互通信息。一个是进程名称,另一个是退出码

进程名的妙处在于它可以在运行期间被修改并作为一种通信手段。

1
2
# 在 Python 中中获得当前进程的名称。
multiprocessing.current_process().name

9. 进程皆有退出码

当进程即将结束时,它还有最后一线机会留下自身的信息:退出码。所有进程在退出的时候都带有数字退出码(0-255),用于指明进程是否顺利结束。

按惯例,退出码为 0 的进程被认为是顺利结束;其他的退出码则表明出现了错误,不同的退出码代表不同的错误。

尽管退出码通常用来表明不同的错误,它们其实是一种通信途径。你只需以适合自己程序的方式来处理各种进程退出码,便打破了传统。

坚持“退出码 0 代表顺利结束”的传统通常是一个不错的主意,这样你的程序就能同其他的 Unix 工具顺畅合作。

退出进程的方式

1. exit

这将使你的进程携带顺利状态码(0)退出。

1
sys.exit(0)

当 exit 被调用时, 在退出之前, 可以定义回调函数。

1
2
3
4
5
6
7
8
9
import sys
import atexit

def p():
print('doing before exit')

atexit.register(p)
sys.exit(0)
print("something")

2. abort

abort 提供了一种从错误进程中退出的通用方法。

1
2
import os
os.abort()

携带退出码 1 退出。

1
Aborted (core dumped)

3. raise

另一种结束进程的方法是使用一个未处理的异常。在生产环境中绝对不想出现这种情况,不过在开发和测试环境中,这几乎是不可避免的。

和之前那些方法不同的是, raise 不会立刻结束进程。它只是抛出一个异常,该异常会沿着调用栈向上传递并可能会得到处理。

如果没有代码对其进行处理,那么这个未处理的异常将会终结该进程。以此种方式结束的进程仍然会调用 atexit 处理程序,并向 STDERR 打印出异常消息和回溯。

1
2
3
4
5
6
7
8
9
import os
import atexit

def p():
print('doing before exit')

atexit.register(p)
raise
print("something")

10. 进程皆可衍生

衍生(forking), Unix中最强大的概念之一

fork(2) 系统调用允许运行中的进程以编程的形式创建新的进程。这个新进程和原始进程一模一样。

子进程从父进程处继承了其所占用内存中的所有内容,以及所有属于父进程的已打开的文件描述符

  1. 子进程是一个全新的进程,所以它拥有自己唯一的 pid。
  2. 子进程的 ppid 就是调用 fork(2)的进程的 pid。
  3. 在 fork(2)调用时,子进程从父进程处继承了所有的文件描述符,也获得了父进程所有的文件描述符的编号。这样,两个进程就可以共享打开的文件、套接字,等等。两个进程就可以共享打开的文件、套接字,等等。
  4. 子进程继承了父进程内存中的所有内容。借助这种方式,一个进程可以将一个 500MB 的代码库(codebase)装入内存,然后该进程衍生出两个子进程, 这些子进程实际上各自享有一份已载入内存代码库的副本。
  5. fork 调用几乎瞬间就可以返回,这样我们就得到了 3 个进程,每个进程都可以使用 500MB 的内存空间。这对于想要在内存中载入多个应用程序实例而言简直就是完美的解决方案。因为只需要一个进程来载入应用程序,而且进程衍生的速度很快,所以这种方法比分别载入 3 个应用程序实例要快得多。
  6. 子进程可以随意更改其内存内容的副本,而不会对父进程造成任何影响。

例子:

1
2
3
4
5
6
import os

if os.fork():
print("entered the if block")
else:
print("entered the else block")

对于 fork 方法的一次调用实际上返回了两次

1
2
entered the if block
entered the else block

用 PID 调试:

1
2
3
parent process pid is 95576
entered the if block from 95576
entered the else block from 95577

解释: if 语句块中的代码是由父进程执行的,而 else 语句块中的代码是子进程执行的。子进程执行完 else 语句块之后退出,父进程则继续运行。

它和 fork 方法的返回值有关。在子进程中, fork 返回 0 。因为 0 为假,所以子进程便执行了 else 语句块中的代码。在父进程中, fork 返回新创建的子进程的 pid。因为此整数值为真, 所以父进程执行的是 if 语句块中的代码。

多核编程

通过生成新的进程,你的代码可以(不能完全保证)被分配到多个 CPU 核心中。

在配备了 4 个 CPU 的系统中,如果衍生出 4 个新进程,那么这些进程会分别由不同的 CPU 来处理,从而实现多核并发(multicore concurrency)。但是并不保证它们会并行操作,在繁忙的系统中,有可能所有 4 个进程都由同一个 CPU 来处理。

fork炸弹

fork(2)创建了一个和旧进程一模一样的新进程。所以如果一个使用了500MB 内存的进程进行了衍生,那么就有 1GB 的内存被占用了。

重复同样的操作10次,很快就会耗尽所有内存。这就是 fork 炸弹。并发前必须确保知道后果。

11. 孤儿进程

如果涉及子进程, 就不能再像我们已经习惯的那样从终端来控制一切。

当通过终端启动单个进程时,通常只有这个进程向 STDOUT 写入,从键盘获取输入或是侦听 Ctrl-C 以待退出。

一旦进程衍生出了子进程,就复杂了,比如此时按 Ctrl-C 哪个进程退出。

例子: 有子进程时按 Ctrl-C 哪个进程退出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os
import time

def main():
pid = os.fork()
if pid == 0:
print("child: pid: {}".format(os.getpid()))
print("child: ppid: {}".format(os.getppid()))
for i in range(5):
print("child: sleeping: {}".format(i + 1))
time.sleep(1)
if i == 4:
print("child father: {}".format(os.getppid()))
else:
print("father pid: {}".format(os.getpid()))

main()

终端运行这个程序

父进程结束后,立刻返回到终端命令提示符下,此时终端被子进程输出到 STDOUT 的内容所重写!

操作系统并不会对子进程区别对待。因此父进程结束后,子进程照常继续运行。

管理孤儿

如何对孤儿进程进行管理 — 涉及两个概念。

  • 第一个是守护进程。守护进程是一种长期运行的进程,为了能够一直保持运行,它们有意作为孤儿进程存在。在后面我们会对其进行详述。
  • 另一个是与脱离终端会话的进程进行通信。你可以使用 Unix 信号来做到这一点。同样我们会在随后的章节中详述。

对于 Linux,当父进程的退出后,如果子进程依然没有退出的话,Linux守护进程systemd就会接管子进程,等待子进程结束后,systemd会自动处理子进程。

12. 写时复制

fork(2) 创建了一个和父进程一模一样的子进程,它包含了父进程在内存中的一切内容。

实实在在地复制所有的数据所产生的系统开销不容小觑,因此现代的Unix 系统采用写时复制(copy-on-write,CoW)的方法来克服这个问题。

顾名思义,CoW 将实际的内存复制操作推迟到了真正需要写入的时候。

所以说父进程和子进程实际上是在共享内存中的数据,直到它们其中的某一个需要对数据进行修改,届时才会进行内存复制,使得两个进程保持适当的隔离。

CoW 固然不错,但编程语言对其是否支持需要确认。

对于 linux 下的 Python,os.fork() 将使用系统的 fork(),因此是写时复制。但要注意 gc 的影响:

Python 的垃圾收集器使用了一种“标记-清除”(mark-and-sweep)的算法。这意味着当垃圾收集器被调用时,它必须对每个已知的对象进行迭代并写入信息,指出该对象是否应该被回收。关键在于垃圾收集器每运行一次,内存中的所有对象都会被写入信息。

因此,在进行衍生之后,首次进行垃圾收集的时候,写时复制所带来的好处会被撤销。


Share