Garland +

理解 Unix 进程阅读笔记

浅入浅出,大概花了一周左右看完了,之前刷过一半英文版的,这次看的是中文版的,译者文笔很不错,读起来丝毫没有生硬的感觉。总的来讲这本书属于非科班程序员快速补一补的那类,其中的每个点单拿出来可能都要写好几篇文章讲,原作是用 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()
  1. 所分配的文件描述符编号是尚未使用的最小的数值
  2. 资源一旦关闭就会释放当时所分配的文件描述符
  3. 由2,关闭的资源是没有文件描述符的
  4. 文件描述符最小值是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

进程名

  1. 文件系统层面:进程可以通过向文件系统写入信息的方式来了解彼此的状态信息
  2. 网络层面:借助网络使用 socket 来同其他进程实现通信
  3. 进程层面:进程名称,退出码

每个进程都有名称,进程在运行期间也可以动态的修改名称

# 没找着官方实现,可以使用第三方包 psutil

进程退出

所有进程在退出的时候都带有数字退出码 (0-255),用于指明进程是否顺利退出。

按惯例,退出码是 0 的进程被认为是正常退出,其他退出码则表示出现了错误。

默认以状态码0退出,也可以传入自定义的状态码,在 exit 执行前会调用 atexit 注册的回调

import sys
sys.exit(1)
# 退出前的回调
import atexit
def p():
    print('doing before exit')
atexit.register(p)
  1. sys.exit:正常退出,会执行 atexit 注册的回调
  2. os._exit:非正常退出,不会执行 atexit 注册的回调
  3. os.abort
  4. raise

Fork

fork可以从一个父进程创建一个子进程,子进程会继承父进程所占内存的所有内容,以及属于父进程的已打开的文件描述符、套接字等,子进程拥有唯一的 pid,子进程的 ppid 即父进程的 pid,并且子进程享有自己单独的内存空间,即子进程的改动不会对父进程产生影响。

fork 的一次调用会在父进程和新创建的子进程中同时返回。在 python 中,子进程收到的值是 0,父进程收到的值是子进程的 pid

fork 炸弹会把内存耗尽

孤儿进程

父进程结束或被退出的进程

  1. 守护进程是一种长期运行的进程
  2. 与脱离终端会话的进程进行通信
# 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

  1. os.wait
  2. os.wait3
  3. os.wait4
  4. os.waitpid

竞争条件(Race conditions)

内核会将退出的进程信息加入队列,这样一来父进程总是能依照子进程退出的顺序接收到信息。

preforking

有一个衍生出多个并发子进程的进程,这个进程看管着这些进程,确保他们能够相应,并对子进程的退出做出回应。

僵尸进程

内核会一直保留已退出的子进程的状态信息,直到父进程使用 .wait 请求这些信息。如果父进程一直发不出请求,那么状态信息就会一直被内核保留着。

detach in ruby: 父进程生成一个新线程,这个线程会等待由指定pid的子进程退 出,确保内核不会一直保留那些状态信息

任何已经结束的进程,如果他的状态信息一直没能被读取,那么他就是一个僵尸进程。

信号

os.wait 是一个阻塞调用

信号发送是不可靠的,如果一个进程收到一个 CHID 的信号,然后执行信号绑定函数,第二个 CHID2 信号又来了,如果第一个信号没有被处理完毕的话,第二个信号就会被丢弃。

可以在信号处理函数中使用 await

信号是一种异步通信,当进程从内核接收到一个信号时,可以执行下列某一操作:

  1. 忽略该信号
  2. 执行特定的操作
  3. 执行默认的操作

信号是以内核为中介由一个进程发送到另一个进程

常用信号:

  1. SIGINT: 终止进程 中断进程 ctrl+C(Term)
  2. SIGTREM: 终止进程 根据需要来关闭程序,可以做一些文件关闭等清理操作 (Term)
  3. SIGKILL: 终止进程 杀死进程 不管做什么都立刻停止(python: 无法)(Term)

信号的动作:

  1. Term: 表示进程会立即结束
  2. Core: 表示进程会立即结束并进程核心转储(栈跟踪)
  3. Ign: 表示进程会忽略该信号
  4. Stop: 表示进程会停止运行(暂停)
  5. 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 的类型来操作

进程可以在任何时候接收到信号。

进程间通信

管道

管道是一个单向数据流,即一个进程拥有管道的一端,另一个进程拥有管道的另一端,然后数据就沿着管道单向传递

默认的管道是匿名管道,所以用于具有亲缘关系进程间的通信;而命名管道是持久化的,支持亮哥独立的进程间的通信。

# 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 作用

  1. 该进程变成一个新会话的会话领导
  2. 该进程变成一个新进程组的组长
  3. 该进程没有控制终端

进程组和会话组

进程组和会话组都和作业控制有关,作业控制即终端处理进程的方法。

# 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() 可以检索当前会话组ID

创建守护进程

  1. 主进程 fork 一个子进程,然后主进程退出,此时子进程变成孤儿进程
  2. 调用该脚本的终端认为该命令已经执行完毕,于是返回终端
  3. 子进程仍拥有从父进程继承而来的组id和会话ID,此时子进程并非会话领导,也非组长
  4. 调用 os.setsid 会使子进程成为一个新进程组和新会话组的组长兼领导(只能从子进程调用)
  5. 然后该子进程 fork 一个孙子进程,然后退出,孙子进程也不是进程组组长和会话领导,由于子进程没有控制终端,所以孙子进程绝对不会有控制终端。终端只能分配给会话领导

这时进程就完全脱离了控制终端可以自主运行。

终端进程

exec: 使用另一个进程来替换当前进程,无法恢复。

通常使用 fork 创建一个新进程,然后用 exec 把这个进程变成其他想要的进程。exec 默认不会关闭任何打开的文件描述符或是进行内存清理

另外发现了个 go 版的

本文部分同步发于豆瓣

REF

言:

Blog

Thoughts

Project