理解 Unix 进程阅读笔记
2018-08-05
浅入浅出,大概花了一周左右看完了,之前刷过一半英文版的,这次看的是中文版的,译者文笔很不错,读起来丝毫没有生硬的感觉。总的来讲这本书属于非科班程序员快速补一补的那类,其中的每个点单拿出来可能都要写好几篇文章讲,原作是用 ruby 实现的,读的时候把部分的例子用 python 实现了下,这里小记一下。
系统调用
系统调用允许用户空间程序间接的与计算机硬件进行交互
Unix man page
#1. 一般命令
#2. 系统调用
#3. C库函数
#4. 特殊文件
man 2 getpid
man 3 malloc
进程
所有的代码都是在进程中执行的。
PID: 系统中运行的所有进程都有的唯一标示符
import os
pid = os.getpid()
ppid = os.getppid()
# shell
ps aux | grep <ppid>
文件描述符
打开资源就会获得一个文件描述符编号,内核靠这个编号来跟踪进程对这个资源的调用。
文件描述符只存在于当前的进程中,进程结束会和被这个进程打开的其他资源一起关闭。
f = open('a')
f.fileno()
- 所分配的文件描述符编号是尚未使用的最小的数值
- 资源一旦关闭就会释放当时所分配的文件描述符
- 由2,关闭的资源是没有文件描述符的
- 文件描述符最小值是3
标准流
每个 Unix 进程都有三个打开的资源
#1. STDIN: 标准输入
#2. STDOUT: 标准输出
#3. STDERR: 标准错误
import sys
sys.stdin.fileno()
sys.stdout.fileno()
sys.stderr.fileno()
# 0 1 2
资源限制
进程能拥有的最大文件描述符数受系统配置而定
import resource
resource.getrlimit(resource.RLIMIT_DATA)
# (软限制, 硬限制)
# (9223372036854775807, 9223372036854775807)
resource.RLIM_INFINITY
# 9223372036854775807 (没有限制)
超过软限制会产生异常,但是可以被修改,所有进程都能修改自己的软限制;只有有足够权限的进程才能够修改硬限制。
超出限制会提示:too many open files
在一些压测工具上可能会有修改最大文件描述符数的需求
环境变量
所有的进程都从父进程处继承环境变量。每一个进程都有环境变量,环境变量对于特定进程而言是全局性的。
import os
os.environ
os.environ.setdefault(k, v)
进程参数
所有进程都可以访问名为 ARGV 的特殊数组
import sys
sys.argv
进程名
- 文件系统层面:进程可以通过向文件系统写入信息的方式来了解彼此的状态信息
- 网络层面:借助网络使用 socket 来同其他进程实现通信
- 进程层面:进程名称,退出码
每个进程都有名称,进程在运行期间也可以动态的修改名称
# 没找着官方实现,可以使用第三方包 psutil
进程退出
所有进程在退出的时候都带有数字退出码 (0-255)
,用于指明进程是否顺利退出。
按惯例,退出码是 0
的进程被认为是正常退出,其他退出码则表示出现了错误。
默认以状态码0退出,也可以传入自定义的状态码,在 exit 执行前会调用 atexit 注册的回调
import sys
sys.exit(1)
# 退出前的回调
import atexit
def p():
print('doing before exit')
atexit.register(p)
- sys.exit:正常退出,会执行 atexit 注册的回调
- os._exit:非正常退出,不会执行 atexit 注册的回调
- os.abort
- raise
Fork
fork可以从一个父进程创建一个子进程,子进程会继承父进程所占内存的所有内容,以及属于父进程的已打开的文件描述符、套接字等,子进程拥有唯一的 pid,子进程的 ppid 即父进程的 pid,并且子进程享有自己单独的内存空间,即子进程的改动不会对父进程产生影响。
fork 的一次调用会在父进程和新创建的子进程中同时返回。在 python 中,子进程收到的值是 0,父进程收到的值是子进程的 pid
fork 炸弹会把内存耗尽
孤儿进程
父进程结束或被退出的进程
- 守护进程是一种长期运行的进程
- 与脱离终端会话的进程进行通信
# coding: utf-8
import os
import time
def run_children():
print('my pid:', os.getpid())
print('my father pid:', os.getppid())
for i in range(5):
print('sleeping', i + 1)
time.sleep(1)
if i == 4:
print('where is father', os.getppid())
def main():
pid = os.fork()
if pid < 0:
return
if pid > 0:
print('-----father---')
print('father pid', os.getpid())
if pid == 0:
print('-----children---')
run_children()
if __name__ == "__main__":
# run in terminal
main()
优化
fork 复制数据会产生不小的开销,现代 unix 系统采用了 COW (copy-on-write) 来克服,即将真正的内存复制操作推迟到了真正需要写入的时候。
也就是说父进程和子进程一开始在共享内存中的数据,直到其中某一个需要对数据进行修改,这时才会进行内存复制。
python 的需要试一下
Babysitting
def main():
pid = os.fork()
if pid < 0:
return
if pid > 0:
print('-----father---')
print('father pid', os.getpid())
os.wait()
print('father died')
if pid == 0:
print('-----children---')
run_children()
os.wait
是一个阻塞调用,父进程一直等待到他某个子进程退出后再继续执行。 os.wait
的返回值是子进程的 pid 和退出状态码 status
- os.wait
- os.wait3
- os.wait4
- os.waitpid
竞争条件(Race conditions)
内核会将退出的进程信息加入队列,这样一来父进程总是能依照子进程退出的顺序接收到信息。
preforking
有一个衍生出多个并发子进程的进程,这个进程看管着这些进程,确保他们能够相应,并对子进程的退出做出回应。
僵尸进程
内核会一直保留已退出的子进程的状态信息,直到父进程使用 .wait 请求这些信息。如果父进程一直发不出请求,那么状态信息就会一直被内核保留着。
detach in ruby: 父进程生成一个新线程,这个线程会等待由指定pid的子进程退 出,确保内核不会一直保留那些状态信息
任何已经结束的进程,如果他的状态信息一直没能被读取,那么他就是一个僵尸进程。
信号
os.wait 是一个阻塞调用
信号发送是不可靠的,如果一个进程收到一个 CHID 的信号,然后执行信号绑定函数,第二个 CHID2 信号又来了,如果第一个信号没有被处理完毕的话,第二个信号就会被丢弃。
可以在信号处理函数中使用 await
信号是一种异步通信,当进程从内核接收到一个信号时,可以执行下列某一操作:
- 忽略该信号
- 执行特定的操作
- 执行默认的操作
信号是以内核为中介由一个进程发送到另一个进程
常用信号:
- SIGINT: 终止进程 中断进程 ctrl+C(Term)
- SIGTREM: 终止进程 根据需要来关闭程序,可以做一些文件关闭等清理操作 (Term)
- SIGKILL: 终止进程 杀死进程 不管做什么都立刻停止(python: 无法)(Term)
信号的动作:
- Term: 表示进程会立即结束
- Core: 表示进程会立即结束并进程核心转储(栈跟踪)
- Ign: 表示进程会忽略该信号
- Stop: 表示进程会停止运行(暂停)
- Cont: 表示进程会恢复运行(继续)
# file1
# coding: utf-8
import os
import signal
from time import sleep
def on_term(a, b):
print("SIGTERM 信号")
if __name__ == '__main__':
signal.signal(signal.SIGTERM, on_term)
print('my pid: ', os.getpid())
sleep(120)
# file2
# coding: utf-8
import os
import signal
if __name__ == '__main__':
os.kill(73045, signal.SIGTERM)
os.kill(73045, signal.SIGKILL)
重新定义
singnal.signal(signalnum, handler)
,根据 handler 的类型来操作
- signal.SIG_IGN: 无视信号
- singal.SIG_DFL: 默认操作
- func: 使用函数定义的操作
进程可以在任何时候接收到信号。
进程间通信
管道
管道是一个单向数据流,即一个进程拥有管道的一端,另一个进程拥有管道的另一端,然后数据就沿着管道单向传递
默认的管道是匿名管道,所以用于具有亲缘关系进程间的通信;而命名管道是持久化的,支持亮哥独立的进程间的通信。
# coding: utf-8
import os
def main():
r, w = os.pipe()
w_ = os.fdopen(w, 'w')
data = 'you will receiving pipe data'
w_.write(data)
w_.close()
r_ = os.fdopen(r)
assert r_.read() == data
assert r_.read() == ''
print('write and read done!')
if __name__ == '__main__':
main()
read 会一直阻塞并试图从管道中读取数据,直到读到一个文件结束符号,表示已经没有数据可以读取了
管道也是一种资源,有自己的文件描述符和其他的一切,因此也可以子进程共享
# coding: utf-8
import os
import sys
import time
r, w = os.pipe()
def deal_child():
print('child writing .... ')
os.close(r)
w_ = os.fdopen(w, 'w')
for i in range(10):
time.sleep(0.1)
w_.write('writing ... {}\n'.format(i))
w_.close()
sys.exit(0)
def deal_father():
print('father reading .... ')
os.close(w)
r_ = os.fdopen(r)
while True:
line = r_.read()
if not line:
break
print('{} father read {} in {}'.format(os.getpid(), line, time.time()))
sys.exit(0)
def main():
pid = os.fork()
if pid < 0:
return
if pid > 0:
deal_father()
if pid == 0:
deal_child()
if __name__ == '__main__':
main()
多进程中,文件描述符也会被 fork,所以会出现4个文件描述符,这里关闭掉不用的两个。管道是一个 IO 对象,可以在他之上调用任意的 IO 方法。即管道中流淌的是数据流。
流与消息
unix socket 是一种只能用于在同一台物理主机中进行通信的套接字
父子进程的 unix socket 通信
IPC: InterProcess Communication
# coding: utf-8
import os
import socket
def main():
father, child = socket.socketpair()
pid = os.fork()
if pid:
print('father {} send message'.format(os.getpid()))
child.close()
father.sendall(b'father ping')
res = father.recv(1024)
print('res from child: {}'.format(res))
father.close()
else:
print('child {} waiting message'.format(os.getpid()))
father.close()
msg = child.recv(1024)
print('msg from father: {}'.format(msg))
child.sendall(b'child pong')
child.close()
if __name__ == '__main__':
main()
管道提供的是单向通信,套接字提供的是双向通信,父套接字可以读写子套接字,子套接字也可以读写父套接字
守护进程
守护进程是在后台运行的进程,不受终端用户控制。
当内核被引导时会产生一个叫做 init 的进程,这个进程的 pid 是 0, 作为所有进程的祖父。它是首个进程没有祖先,它的 pid 是1
孤儿进程的 ppid 始终是 1,这时内核能够确保一直运行的唯一进程。
# coding: utf-8
import time
import os
import sys
def main():
pid = os.fork()
if pid:
time.sleep(2)
father_pid = os.getpid()
print('father {} quit...'.format(father_pid))
sys.exit()
else:
count = 0
while True:
time.sleep(0.5)
print('child pid is: {}, ppid is: {}'.format(os.getpid(), os.getppid()))
count += 1
if count >= 10:
sys.exit()
if __name__ == '__main__':
main()
setsid 作用
- 该进程变成一个新会话的会话领导
- 该进程变成一个新进程组的组长
- 该进程没有控制终端
进程组和会话组
进程组和会话组都和作业控制有关,作业控制即终端处理进程的方法。
- 进程组:每个进程都属于某个组,每个组都有唯一的整数ID,进程组是一个相关进程的集合,通常是父进程与其子进程。(os.setpgrp, os.getpgrp)
- 一般来说,进程组ID和进程组组长的pid相同,进程组组长是终端命令的发起进程。
# coding: utf-8
"""
在 terminal 里执行
"""
import os
def main():
pid = os.fork()
if pid == 0:
print('child {} has father {} in pgroup {}'.format(os.getpid(), os.getppid(), os.getpgrp()))
else:
print('father {} in pgroup {}'.format(os.getpid(), os.getpgrp()))
if __name__ == '__main__':
main()
子进程有唯一的pid,子进程的组id是从父进程中继承而来,并且子进程和父进程都是同一个进程组的成员。
终端会接收信号,并将其转发给前台进程组中的所有进程。
会话组
会话组是更高一级的抽象,是进程组的集合。
一个会话组可以依附一个终端,也可以不依附于任何一个终端,比如守护进程。
终端:发送给会话领导的信号被被转发到该会话中的所有进程组内,然后在被转发到这些进程组中的所有进程
os.getsid(
创建守护进程
- 主进程 fork 一个子进程,然后主进程退出,此时子进程变成孤儿进程
- 调用该脚本的终端认为该命令已经执行完毕,于是返回终端
- 子进程仍拥有从父进程继承而来的组id和会话ID,此时子进程并非会话领导,也非组长
- 调用
os.setsid
会使子进程成为一个新进程组和新会话组的组长兼领导(只能从子进程调用) - 然后该子进程 fork 一个孙子进程,然后退出,孙子进程也不是进程组组长和会话领导,由于子进程没有控制终端,所以孙子进程绝对不会有控制终端。终端只能分配给会话领导
这时进程就完全脱离了控制终端可以自主运行。
终端进程
exec: 使用另一个进程来替换当前进程,无法恢复。
通常使用 fork 创建一个新进程,然后用 exec 把这个进程变成其他想要的进程。exec 默认不会关闭任何打开的文件描述符或是进行内存清理
另外发现了个 go 版的
本文部分同步发于豆瓣