Python—process、thread、coroutines(7)

182 阅读16分钟

进程(Process)

进程是操作系统中资源分配的基本单位,每个进程都有自己的内存空间、数据和代码。不同进程之间相互独立,通信比较复杂。多进程适合用于计算密集型任务,因为每个进程可以在不同的 CPU 上运行,充分利用多核处理器。

fork 实现多进程

Unix/Linux操作系统提供了一个fork()系统调用,fork() 操作系统自动把当前进程(称为父进程)复制了一份(称为子进程)。

通过 fork 调用,进程在接到新任务时能派生子进程处理。Apache 服务器通过父进程监听端口,收到请求时 fork 子进程处理。

os.fork() 是一个系统调用,用于创建一个新的子进程。它在当前进程(父进程)上调用,并返回两次:

  • 在父进程中返回子进程的进程ID(PID)
  • 在子进程中返回 0,表示它是一个子进程。
import os
# 返回当前进程ID
print(os.getpid())

# 调用os.fork 会有两次返回
pid = os.fork()
print(pid)


"""执行结果:
8723  # 父进程的 PID
8724  # 父进程中打印的子进程的 PID
0     # 子进程中打印的值,表示它是子进程
"""

multiprocessing 跨平台实现多进程

编写多进程服务程序,Unix/Linux无疑是最佳选择,因为Windows不支持 fork 调用。不过,Python作为跨平台语言,通过 multiprocessing 模块提供了跨平台的多进程支持。

import multiprocessing
import os


def runin_process(name):
    print(f'run in process,the sub process id: {os.getpid()}, name: {name}')


if __name__ == '__main__':
    print(f'parent process id is {os.getpid()}')
    p = multiprocessing.Process(target=runin_process,args=('雨之仙界',))
    print('sub process will start')
    p.start() # 启动进程
    p.join() # 等待子进程运行结束
    print(f'sub process exec end')

"""执行结果
parent process id is 9430
sub process will start
run in process,the sub process id: 9435, name: 雨之仙界
sub process exec end
"""

multiprocessing pool 进程池

如果要启动大量的子进程,可以用进程池的方式批量创建子进程

import multiprocessing
import os
import random
import time


def runin_process_task(index):
    print('subprocess[%s] %s started' % (os.getpid(), index))
    start = time.time()
    time.sleep(random.random() * 3)
    end = time.time()
    print('subprocess[%s] %s finished in %.2f seconds' % (os.getpid(), index, end - start))


if __name__ == '__main__':
    print('parent process id is %s' % os.getpid())
    pool = multiprocessing.Pool(3)
    for i in range(5):
        pool.apply_async(runin_process_task, args=(i,))
    print('waiting all subprocess done')
    pool.close()
    pool.join()
    print('all subprocess done')

"""执行结果
parent process id is 9752
waiting all subprocess done
subprocess[9754] 0 started
subprocess[9755] 1 started
subprocess[9756] 2 started
subprocess[9754] 0 finished in 0.34 seconds
subprocess[9754] 3 started
subprocess[9756] 2 finished in 2.18 seconds
subprocess[9756] 4 started
subprocess[9755] 1 finished in 2.38 seconds
subprocess[9754] 3 finished in 2.58 seconds
subprocess[9756] 4 finished in 0.77 seconds
all subprocess done
"""
  • pool = multiprocessing.Pool(3) 表示最多同时执行3个进程。这是Pool设计的限制,并不是操作系统的限制。Pool的默认大小是CPU的核数。

  • pool.join()等待所有子进程执行完毕,调用join()之前必须先调用close()

  • pool.close()调用之后就不能继续添加新的Process

请注意输出的结果,task 012是立刻执行的,而task 3要等待前面某个task完成后才执行。

subprocess 子进程

当子进程是一个外部进程。通常需要控制子进程的输入和输出。subprocess模块可以方便地启动一个子进程,然后控制其输入和输出。

在Python代码中运行命令ls -al

import subprocess

"""
subprocess.call() 是 subprocess 模块中的一个方法,用于执行外部命令并等待其执行完成。
它会阻塞程序,直到外部命令执行结束,然后返回该命令的退出码(exit code)。
"""
r0 = subprocess.call(['ls', '-al'])
print('Exit code:', r0)

在Python中执行命令nslookup,然后输入:

set q=mx
python.org
exit
import subprocess

"""
subprocess.Popen 启动一个子进程
1. ['nslookup']:命令和参数的列表,表示启动 nslookup。
2. stdin=subprocess.PIPE:允许向子进程的标准输入发送数据(即模拟键盘输入)。
3. stdout=subprocess.PIPE:允许从子进程的标准输出读取数据(即获取命令输出)。
4. stderr=subprocess.PIPE:允许从子进程的标准错误输出读取数据(即获取错误信息)。
"""
r1 = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

"""
communicate() 用于与子进程进行交互,发送数据到子进程的标准输入(stdin),并读取标准输出(stdout)和标准错误输出(stderr)
"""
output, err = r1.communicate(b'set q=mx\npython.org\nexit\n')

"""
utf-8编码转换输出
"""
print(output.decode('utf-8'))
"""
r1.returncode 获取子进程的退出状态码。如果返回值为 0,表示进程成功执行;其他值表示出错。
"""
print('Exit code:', r1.returncode)
print(r1)

IPC 进程之间的通信

在 Python 中,进程间通信(Inter-Process Communication,简称 IPC)通常是指不同进程之间交换数据的机制。由于每个进程都有自己的内存空间,不能直接共享数据,因此需要使用专门的 IPC 方法来实现数据传递。

Python 提供了几种常用的进程间通信方式,主要通过 multiprocessing 模块来实现。常见的进程间通信方式包括:

  • Queue:适用于多个进程间的消息传递,支持先进先出(FIFO)的方式。
  • Pipe:适用于两个进程之间的双向通信。
  • Value 和 Array:适用于简单数据类型(如整数、浮点数)的共享内存。
  • Manager:适用于复杂的数据结构(如字典、列表等)的共享。

主进程和子进程通过 Queue 进行通信,队列在多进程环境下是安全的,不需要额外的锁机制。

import multiprocessing

def worker(q0):
    # 向队列中放入数据
    q0.put('Hello from worker!')

if __name__ == '__main__':
    # 创建队列对象
    q = multiprocessing.Queue()

    # 创建子进程
    p = multiprocessing.Process(target=worker, args=(q,))
    p.start()

    # 从队列中获取数据
    print(q.get())  # 输出 'Hello from worker!'

    p.join()  # 等待子进程结束

Pipe 是用于两个进程之间进行双向通信的机制。通过 Pipe() 创建的管道对象提供了两个连接端,分别用于发送和接收数据。

import multiprocessing

def worker(conn):
    # 向管道发送数据
    conn.send('Hello from worker!')
    conn.close()

if __name__ == '__main__':
    # 创建管道对象
    parent_conn, child_conn = multiprocessing.Pipe()

    # 创建子进程
    p = multiprocessing.Process(target=worker, args=(child_conn,))
    p.start()

    # 从管道接收数据
    print(parent_conn.recv())  # 输出 'Hello from worker!'

    p.join()  # 等待子进程结束

ValueArray 提供了共享内存,用于在进程之间共享数据。这种方式适用于共享基本数据类型(如整数、浮点数等)。

import multiprocessing

def worker(shared_value0):
    # 修改共享值
    shared_value0.value = 10

if __name__ == '__main__':
    # 创建了一个共享内存对象,可以在多个进程间共享数据
    shared_value = multiprocessing.Value('i', 0)

    # 创建子进程
    p = multiprocessing.Process(target=worker, args=(shared_value,))
    p.start()

    p.join()  # 等待子进程结束

    # 输出共享内存中的值
    print(shared_value.value)  # 输出 10

Manager 提供了一个更加灵活的共享对象机制,允许在进程间共享复杂的数据结构(如列表、字典等)。

import multiprocessing


def worker(shared_dict0):
    # 向共享字典添加数据
    shared_dict0['key'] = 'value'


if __name__ == '__main__':
    # 创建一个共享管理器,允许多个进程访问同一个对象
    with multiprocessing.Manager() as manager:
        shared_dict = manager.dict()  # 创建共享字典对象

        # 创建子进程
        p = multiprocessing.Process(target=worker, args=(shared_dict,))
        p.start()

        p.join()  # 等待子进程结束

        # 输出共享字典中的数据
        print(shared_dict)  # 输出 {'key': 'value'}

线程(Thread)

进程由若干线程组成,每个进程至少有一个线程,多任务可以通过多进程或单个进程内的多线程来实现。

由于线程是操作系统原生支持的执行单元,因此,许多高级编程语言,包括Python,都内置了多线程的支持。Python的线程是基于POSIX线程(真实的操作系统线程),而非模拟的线程。

Python提供了两个相关模块:_threadthreading。其中,_thread 是底层模块,而 threading 是封装了 _thread 的高级模块。绝大多数情况下,我们应优先使用 threading 模块。

import threading


def runin_threading():
    print('thread[%s] started' % threading.current_thread().name)
    for i in range(5):
        print('thread[%s][%s] started' % (threading.current_thread().name, i))
    print('thread[%s] finished' % threading.current_thread().name)

# 创建线程
t = threading.Thread(target=runin_threading, name='TestThread')
t.start()
t.join()
print('thread[%s] finished' % threading.current_thread().name)

"""执行结果
thread[TestThread] started
thread[TestThread][0] started
thread[TestThread][1] started
thread[TestThread][2] started
thread[TestThread][3] started
thread[TestThread][4] started
thread[TestThread] finished
thread[MainThread] finished
"""

线程锁 Lock

多线程和多进程的主要区别在于,多进程中每个进程有独立的变量拷贝,互不影响;而多线程中,所有线程共享同一变量,因此多个线程同时修改同一变量可能导致数据混乱。

import threading
import time

# 余额:
balance = 0


def add_balance(n):
    global balance
    a = balance
    # 模拟程序执行1秒
    time.sleep(1)
    balance = a + n


def min_balance(n):
    global balance
    a = balance
    # 模拟程序执行2秒
    time.sleep(2)
    balance = a - n


t1 = threading.Thread(target=add_balance, args=(3,))
t2 = threading.Thread(target=min_balance, args=(3,))
t1.start()
t2.start()
t1.join()
t2.join()

print(balance) # 结果为 -3

加锁执行

lock = threading.Lock()

def add_balance(n):
    global balance
    with lock:
        a = balance
        # 模拟程序执行1秒
        time.sleep(1)
        balance = a + n


def min_balance(n):
    global balance
    with lock:
        a = balance
        # 模拟程序执行2秒
        time.sleep(2)
        balance = a - n

多核 CPU

import multiprocessing
import threading


def loop():
    x = 0
    while True:
        x = x ^ 1

for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。

而如果使用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?

因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

GIL是Python解释器设计的问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。

所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现。

Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

ThreadLocal

在Python中,ThreadLocalthreading模块提供的一个类,用于在不同线程之间保持独立的数据。每个线程可以在ThreadLocal对象中存储自己的独立数据,这些数据对其他线程是不可见的。可通过ThreadLocal对象的_thread_local属性来存取属于该线程的特有数据

ThreadLocal使用场景:为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等。

import threading

class DatabaseConnection:
    def __init__(self, thread_name):
        self.thread_name = thread_name
        self.connection = f"Connection for {thread_name}"

    def get_connection(self):
        return self.connection

# 创建ThreadLocal对象来存储线程特定的数据库连接
db_connection = threading.local()

def get_db_connection():
    if not hasattr(db_connection, "connection"):
        # 如果当前线程没有连接,则创建一个新的
        db_connection.connection = DatabaseConnection(threading.current_thread().name)
    return db_connection.connection.get_connection()

def worker():
    print(f"Thread {threading.current_thread().name} uses {get_db_connection()}")

threads = []
for i in range(3):
    t = threading.Thread(target=worker)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

Process vs Thread

Master-Worker 模式

实现多任务通常采用 Master-Worker 模式

  • Master 负责分配任务。
  • Worker 负责执行任务。

在不同的实现方式下,Master 和 Worker 的角色有所不同:

  • 多进程模式:主进程是 Master,其他进程是 Worker。
    • 优点: 稳定性高:子进程崩溃不会影响主进程。进程相互独立,减少了线程间的资源冲突。
    • 缺点:创建进程开销大,尤其在 Windows 下。操作系统能同时运行的进程数量有限,过多进程会影响系统调度。
  • 多线程模式:主线程是 Master,其他线程是 Worker。
    • 优点: 效率较高,特别在 Windows 下。
    • 缺点: 稳定性差:任何一个线程崩溃可能导致整个进程崩溃,因为所有线程共享进程的内存。
    • 应用:微软的 IIS 服务器采用多线程模式,但由于稳定性差,后来采取了混合模式。

线程切换的开销

无论多进程还是多线程,任务过多时,线程切换的开销会影响效率。
假设有多项任务,切换任务需要保存和恢复状态,类似操作系统在切换进程或线程时所做的工作。如果任务数量过多,系统可能会把大部分时间花费在任务切换上,导致效率下降,最常见的现象是硬盘狂响,系统假死。

计算密集型 vs IO 密集型任务

任务的类型决定是否需要多任务支持:

计算密集型任务

  • 特点:需要大量的 CPU 计算,如圆周率计算、视频解码等。
  • 适合场景:任务数量应等于 CPU 核心数,任务越多,切换的开销越大,效率下降。
  • 语言选择:Python 不适合处理计算密集型任务,C 语言更适合。

IO 密集型任务

  • 特点:涉及大量网络、磁盘 IO 操作,CPU 消耗较少。
  • 适合场景:Web 应用等,任务越多,CPU 效率越高,但也有上限。
  • 语言选择:Python 等脚本语言非常适合 IO 密集型任务。

异步 IO

由于 CPU 和 IO 操作速度差异,单进程单线程模型会导致任务无法并行执行。为此,现代操作系统支持 异步 IO,允许单进程处理多个任务,从而提高效率。 事件驱动模型

  • 例子:Nginx 使用事件驱动模型,单进程就能高效支持多任务。
  • 在多核 CPU 上,Nginx 可以使用多个进程来充分利用多核性能。 Python 协程
  • 协程是单线程异步编程模型,使得多任务编程更高效。
  • 通过协程,可以在单线程中高效处理多任务,广泛应用于 IO 密集型任务。

分布式进程

Thread和Process中,应当优选Process,因为Process更稳定,而且,Process可以分布到多台机器上,而Thread最多只能分布到同一台机器的多个CPU上。

Python的 multiprocessing 模块不但支持多进程,其中managers 子模块还支持把多进程分布到多台机器上。一个服务进程可以作为调度者,将任务分布到其他多个进程中。

Master/Worker模型

distributed_manager.py

启动一个 manager 并暴露网络端口

from multiprocessing.managers import BaseManager
from queue import Queue

class QueueManager(BaseManager):
    pass

task_queue = Queue()
result_queue = Queue()

def get_task_queue():
    return task_queue

def get_result_queue():
    return result_queue

QueueManager.register('get_task_queue', callable=get_task_queue)
QueueManager.register('get_result_queue', callable=get_result_queue)

def start_manager():
    queue_manager = QueueManager(address=('', 11202), authkey=b'a123456')
    queue_manager.start()
    return queue_manager

if __name__ == '__main__':
    manager = start_manager()
    print("Server started, waiting for client connections...")

distributed_server.py

启动服务端进程,将任务添加到队列中,等待客户端消费队列中的任务并且获取任务消费结果

from multiprocessing import Process

from dawn.process.mutiprocessing.distributed_manager import start_manager


def server():
    manager = start_manager()  # 启动Manager,绑定到11202端口
    task_queue = manager.get_task_queue()  # 获取任务队列
    result_queue = manager.get_result_queue()  # 获取结果队列

    # 模拟添加任务到队列中
    for i in range(5):
        task_queue.put(i)
        print(f"Task {i} added to the task queue.")

    # 模拟获取任务的结果
    for _ in range(5):
        result = result_queue.get(timeout=100)
        print(f"Received result: {result}")

    # 关闭QueueManager
    manager.shutdown()


if __name__ == '__main__':
    # 启动服务器进程
    server_process = Process(target=server)
    server_process.start()
    server_process.join()

distributed_client.py

连接服务,接收服务端任务并将消费结果放到另一个队列中

from multiprocessing.managers import BaseManager


class QueueManager(BaseManager):
    pass

# 注册方法,使得客户端可以访问
QueueManager.register('get_task_queue')
QueueManager.register('get_result_queue')

# 连接到任务队列并从中获取任务
def client():
    # 连接到服务器上指定的端口
    manager = QueueManager(address=('', 11202), authkey=b'a123456')
    manager.connect()

    task_queue = manager.get_task_queue()
    result_queue = manager.get_result_queue()

    # 处理任务
    while not task_queue.empty():
        task = task_queue.get()
        print(f"Processing task: {task}")
        result = task * 2  # 假设任务是乘以2
        result_queue.put(result)

if __name__ == '__main__':
    client()

上面的示例,假设启动多个worker,就可以把任务分布到几台甚至几十台机器上。

协程(coroutines)

Python 中的 协程(coroutines) 又称微线程,纤程,是一种用于实现并发操作的编程方式,它是基于生成器(generator)和 asyncio 库的一种异步编程模型。协程可以使得 Python 在处理 I/O 密集型任务时,不需要等待任务完成,可以继续处理其他任务。

Python中的协程是基于generator实现的,在 generator 中,我们不但可以通过 for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。

但Python的 yield 不但可以返回一个值,它还可以接收调用者发出的参数。

def consumer():
    r = ''
    while True:
        # 接收生产者 send 的数据
        n = yield r
        if not n: # 如果没有数据直接结束
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    # 启动协程,调用生成器的 `__next__` 方法,进入 `yield` 暂停状态
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n) # 将数据发送给消费者,并接收消费者的响应
        print('[PRODUCER] Consumer return: %s' % r)
    c.close() # 关闭消费者协程

consumer = consumer()
produce(consumer)

协程本质上是一种 可暂停和恢复执行的函数,通常在执行过程中遇到 await 关键字时挂起,等到某个操作完成后再继续执行。

import asyncio
# `async def`:用于定义一个协程函数,表示该函数是异步的,
# 可以使用`await`来等待其他协程。
async def run():
    print("Hello world!")
    
    """
    asyncio.sleep()也是一个async函数,所以线程不会等待asyncio.sleep()
    
    如果把asyncio.sleep(1)看成是一个耗时1秒的IO操作,
    在此期间,主线程并未等待,而是去执行EventLoop中其他可以执行的async函数了,
    因此可以实现并发执行。
    """
    # 异步调用asyncio.sleep(1):
    await asyncio.sleep(1)
    print("Hello again!")

# 要运行协程,可通过事件循环(asyncio)来启动它。
# 可以通过 asyncio.run() 来运行协程。
asyncio.run(run())

asyncio.gather()同时调度多个async函数;如果把asyncio.sleep()换成真正的IO操作,则多个并发的IO操作实际上可以由一个线程并发执行。

import asyncio
import threading


async def task(name):
    print("start task %s! (%s)" % (name, threading.current_thread))
    await asyncio.sleep(2)
    print("end task %s" % name)
    return name


async def main():
    # 并发运行任务, 等待任务的执行,并获取执行结果
    r0 = await asyncio.gather(task('task1'), task('task2'))
    print(r0)

asyncio.run(main())

asyncio的异步网络连接来获取sina、sohu和163的网站首页

import asyncio

async def wget(host):
    print(f"wget {host}...")
    # 连接80端口:
    reader, writer = await asyncio.open_connection(host, 80)
    # 发送HTTP请求:
    header = f"GET / HTTP/1.0\r\nHost: {host}\r\n\r\n"
    writer.write(header.encode("utf-8"))
    # 等待直到缓冲区数据被完全发送
    await writer.drain()

    # 读取HTTP响应:
    while True:
        line = await reader.readline()
        if line == b"\r\n":
            break
        print("%s header > %s" % (host, line.decode("utf-8").rstrip()))
    # Ignore the body, close the socket
    writer.close()
    await writer.wait_closed()
    print(f"Done {host}.")

async def main():
    await asyncio.gather(wget("www.sina.com.cn"), wget("www.sohu.com"), wget("www.163.com"))

asyncio.run(main())

aiohttp

asyncio 可以实现单线程并发 I/O 操作,特别适用于服务器端的高并发支持,如 Web 服务器。它支持 TCP、UDP、SSL 等协议,aiohttp 是基于 asyncio 实现的 HTTP 框架。

定义处理不同URL的async函数,通过app.add_routes()添加映射,最后通过run_app()以asyncio的机制启动整个处理流程。

from aiohttp import web

async def index(request):
    text = "<h1>Index Page</h1>"
    return web.Response(text=text, content_type="text/html")

async def hello(request):
    name = request.match_info.get("name", "World")
    text = f"<h1>Hello, {name}</h1>"
    return web.Response(text=text, content_type="text/html")

app = web.Application()

# 添加路由:
app.add_routes([web.get("/", index), web.get("/{name}", hello)])

if __name__ == "__main__":
    web.run_app(app)