Python实现多进程间通信的方法总结

0 引言

众所周知,python因为GIL的存在,其多线程只能在一个CPU中调度,对于计算密集型任务完全不能充分利用多核资源,所以需要Python多进程编程。多进程程序最大的性能瓶颈往往出在进程间通信,尤其是进程之间大数据量传递时,选用合适的进程间通信(IPC)方式尤其重要。
本文全面总结Python中进程间通信的各种方法。

1 进程间通信

1.1 概念

进程是操作系统分配和调度系统资源(CPU、内存)的基本单位。进程之间是相互独立的,每启动一个新的进程相当于把数据进行了一次克隆,子进程里的数据修改无法影响到主进程中的数据,不同子进程之间的数据也不能直接共享,这是多进程在使用中与多线程最明显的区别。

进程间通信方法有很多:
(1)信号量( semaphore ) : 信号量是一个共享资源访问者的计数器,可以用来控制多个进程对共享资源的并发访问数。它常作为一种锁机制,防止指定数量的进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段,用于控制某共享资源的并发访问者数量。

(2)信号 ( signal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

(3)管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

(4)有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

(5)消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

(6)共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

(7)套接字( socket ) : socket也是一种进程间通信机制,与其他通信机制不同的是,它主要用于不同机器间的进程通信,同一机器内的进程通信采用此方式是有些浪费的。

(8) 文件:使用文件进行通信是最简单的一种通信方式,一个进程将结果输出到临时文件,另一个进程从文件中读出来。

python提供了多种方法实现了多进程中间的通信和数据共享。

1.2 基于信号量(Semaphore)的IPC

from threading import Semaphore
db_semaphore = Semaphore(2) # 创建信号量
database = []
def insert(data):
        '''
        如果insert(data)是一个子进程任务,
        需要在创建子进程时将信号量db_semaphore作为参数传入子进程任务函数;
        '''
        db_semaphore.acquire() # 尝试获取信号量
        database.append(data)  # 如果信号量获取成功就处理
        db_semaphore.release() # 释放信号量

1.3 基于信号(Signal)的IPC

Python标准库signal模块提供了在 Python 程序中使用信号处理程序的机制。信号处理程序总是在 Python 主线程中执行,即使信号是在另一个线程中接收的。所以信号不能用作线程间通信的手段,如果需要线程间通信可以使用 threading 模块中的同步函数。此外,只允许主线程设置新的信号处理程序。

信号通信的应用:
(1)故障定位技术(进程的底层故障,例如进程突然中断和一些可能性较小的故障);
(2)对进程的流程控制 ;

signal常用的几个函数
(1)os.kill(pid,sig)
用于从一个进程中发送一个信号给某个进程。
参数解析:
pid 指定发送信号的进程号
sig 要发送的信号代号(需要通过signal模块获取)
(2)signal.alarm(sec)
设置时钟信号,在一定时间后给自身发送一个SIGALRM信号。非阻塞函数,sec为定时长度。
原理:
时钟的创建是进程交由操作系统内核(kernal)帮助创建的,时钟和进程之间是异步执行的,当时钟到时,内核会发送信号给进程,进程接收信号进行相应的响应操作。这就是所谓的python异步处理方案。后面的时钟会覆盖前面的时钟,一个进程。只有一个挂起的时钟

import signal, os

def handler(signum, frame):
    '''
    信号处理程序
    '''
    print('Signal handler called with signal', signum)
    raise OSError("Couldn't open device!")

# 设置信号处理器
signal.signal(signal.SIGALRM, handler)
# 设置5s的定时,时间到后给自身发送一个SIGALRM信号
signal.alarm(5)

# open()可能无限等待,或者打开资源的时间过长
fd = os.open('/dev/ttyS0', os.O_RDWR)
# 关闭定时
signal.alarm(0)

1.4 基于管道(Pipe)的IPC

只有父进程与子进程之前可以用管道传递数据。通过os.read()和os.write()来对文件描述符进行读写操作,使用os.close()关闭描述符。

import os
import sys
import math


def slice(mink, maxk):
    s = 0.0
    for k in range(mink, maxk):
        s += 1.0/(2*k+1)/(2*k+1)
    return s


def pi(n):
    childs = {}
    unit = n / 10
    for i in range(10):  # 分10个子进程
        mink = unit * i
        maxk = mink + unit
        r, w = os.pipe()
        pid = os.fork()
        if pid > 0:
            childs[pid] = r  # 将子进程的pid和读描述符存起来
            os.close(w)  # 父进程关闭写描述符,只读
        else:
            os.close(r)  # 子进程关闭读描述符,只写
            s = slice(mink, maxk)  # 子进程开始计算
            os.write(w, str(s))
            os.close(w)  # 写完了,关闭写描述符
            sys.exit(0)  # 子进程结束
    sums = []
    for pid, r in childs.items():
        sums.append(float(os.read(r, 1024)))
        os.close(r)  # 读完了,关闭读描述符
        os.waitpid(pid, 0)  # 等待子进程结束
    return math.sqrt(sum(sums) * 8)


print(pi(10000000))

1.5 基于有名管道(fifo)的IPC

相对于管道只能用于父子进程之间通信,Unix还提供了有名管道可以让任意进程进行通信。有名管道又称fifo,它会将自己注册到文件系统里一个文件,参数通信的进程通过读写这个文件进行通信。
fifo要求读写双方必须同时打开才可以继续进行读写操作,否则打开操作会堵塞直到对方也打开。

import os
import sys
import math


def slice(mink, maxk):
    s = 0.0
    for k in range(mink, maxk):
        s += 1.0/(2*k+1)/(2*k+1)
    return s


def pi(n):
    childs = []
    unit = n / 10
    fifo_path = "/tmp/fifo_pi"
    os.mkfifo(fifo_path)  # 创建named pipe
    for i in range(10):  # 分10个子进程
        mink = unit * i
        maxk = mink + unit
        pid = os.fork()
        if pid > 0:
            childs.append(pid)
        else:
            s = slice(mink, maxk)  # 子进程开始计算
            with open(fifo_path, "w") as ff:
                ff.write(str(s) + "\n")
            sys.exit(0)  # 子进程结束
    sums = []
    while True:
        with open(fifo_path, "r") as ff:
            # 子进程关闭写端,读进程会收到eof
            # 所以必须循环打开,多次读取
            # 读够数量了就可以结束循环了
            sums.extend([float(x) for x in ff.read(1024).strip().split("\n")])
            if len(sums) == len(childs):
                break
    for pid in childs:
        os.waitpid(pid, 0)  # 等待子进程结束
    os.unlink(fifo_path)  # 移除named pipe
    return math.sqrt(sum(sums) * 8)


print(pi(10000000))

1.6 基于消息队列(Queue)的IPC

操作系统提供了跨进程的消息队列对象可以让我们直接使用,但是python没有默认提供包装好的api来直接使用。我们必须使用第三方扩展来完成OS消息队列通信。第三方扩展是通过使用Python包装的C实现来完成的。
操作系统提供的消息队列有两种形式,一种是POSIX消息队列,另一种是System V 消息队列,有些操作系统两者都支持,有些只支持其中的一个。

System V 与 POSIX的区别:
(1)System V 存在时间比较老,包括linux等许多系统都支持,但是接口复杂,并且可能各平台上实现略有区别(如ftok的实现及限制)。
(2)POSIX是新标准,现在多数类UNIX系统也已实现,如果只是开发的话,那么还是POSIX好,因为语法简单,并且各平台上实现都一样。

Python中对应的实现工具有posix_ipc与sysv_ipc,二者为同一作者,可以通过pip安装。用法这里就不演示了,可以参考资料:http://semanchuk.com/philip/posix_ipc/。

**注意:**使用posix_ipc与sysv_ipc需要谨慎,如果你的代码需要跨平台,比如在Windows和linux系统上使用,需要:Windows + Cygwin 1.7,Linux with kernel ≥ 2.6

1.7 基于共享内存的IPC

共享内存也是非常高效的多进程通信方式,操作系统负责将同一份物理地址的内存映射到多个进程的不同的虚拟地址空间中。进而每个进程都可以操作这份内存。考虑到物理内存的唯一性,它属于临界区资源,需要在进程访问时搞好并发控制,比如使用信号量。我们通过一个信号量来控制所有子进程的顺序读写共享内存。

python标准库中实现共享内存通信的工具有mmap,但是该库只能用于基本类型,且需要预先分配存储空间,对于自定义类型的对象使用起来有诸多不便。
比较好用的第三方工具有apache开源的pyarrow,可通过pip install pyarrow直接安装,不需要预先定义存储空间且任意可序列化的对象均可存入共享内存。但使用时需要注意:pyarrow反序列化的对象为只读对象不可修改其值,想要修改对象可先通过对象copy。

1.8 基于套接字(Socket)的IPC

看例子:

import os
import sys
import math
import socket


def slice(mink, maxk):
    s = 0.0
    for k in range(mink, maxk):
        s += 1.0/(2*k+1)/(2*k+1)
    return s


def pi(n):
    childs = []
    unit = n / 10
    servsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 注意这里的AF_INET表示普通套接字
    servsock.bind(("localhost", 0))  # 0表示随机端口
    server_address = servsock.getsockname()  # 拿到随机出来的地址,给后面的子进程使用
    servsock.listen(10)  # 监听子进程连接请求
    for i in range(10):  # 分10个子进程
        mink = unit * i
        maxk = mink + unit
        pid = os.fork()
        if pid > 0:
            childs.append(pid)
        else:
            servsock.close()  # 子进程要关闭servsock引用
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.connect(server_address)  # 连接父进程套接字
            s = slice(mink, maxk)  # 子进程开始计算
            sock.sendall(str(s))
            sock.close()  # 关闭连接
            sys.exit(0)  # 子进程结束
    sums = []
    for pid in childs:
        conn, _ = servsock.accept()  # 接收子进程连接
        sums.append(float(conn.recv(1024)))
        conn.close()  # 关闭连接
    for pid in childs:
        os.waitpid(pid, 0)  # 等待子进程结束
    servsock.close()  # 关闭套接字
    return math.sqrt(sum(sums) * 8)


print(pi(10000000))

1.9 基于临时文件(File)的IPC

文件名可以使用子进程的进程id来命名予以区分,进程随时都可以通过os.getpid()来获取自己的进程id。

import os
import sys
import math


def slice(mink, maxk):
    s = 0.0
    for k in range(mink, maxk):
        s += 1.0/(2*k+1)/(2*k+1)
    return s


def pi(n):
    pids = []
    unit = n / 10
    for i in range(10):  # 分10个子进程
        mink = unit * i
        maxk = mink + unit
        pid = os.fork()
        if pid > 0:
            pids.append(pid)
        else:
            s = slice(mink, maxk)  # 子进程开始计算
            with open("%d" % os.getpid(), "w") as f:
                f.write(str(s))
            sys.exit(0)  # 子进程结束
    sums = []
    for pid in pids:
        os.waitpid(pid, 0)  # 等待子进程结束
        with open("%d" % pid, "r") as f:
            sums.append(float(f.read()))
        os.remove("%d" % pid)  # 删除通信的文件
    return math.sqrt(sum(sums) * 8)


print(pi(10000000))

2 一些经验

开发中的应用总结:
(1)仅进程同步不涉及数据传输,可以使用信号、信号量;
(2)若进程间需要传递少量数据,可以使用管道、有名管道、消息队列;
(3)若进程间需要传递大量数据,最佳方式是使用共享内存,推荐使用pyarrow,这样减少数据拷贝、传输的时间内存代价;
(4)跨主机的进程间通信(RPC)可以使用socket通信。

3 参考资料

[1].python官网文档
[2].https://zhuanlan.zhihu.com/p/37370601

你可能感兴趣的:(Python)