Python 学习笔记10 - 进程 Process 和线程 Thread

多任务的实现有3种方式:

  • 多进程模式;
  • 多线程模式;
  • 多进程+多线程模式。

多进程

Unix/Linux操作系统提供了一个 fork() 系统调用。调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回

子进程永远返回0,而父进程返回子进程的ID

Python 的 os 模块封装了常见的系统调用,其中就包括

  • os.fork() 创建子进程
  • os.getpid() 获取自身 ID
  • os.getppid() 获取父进程 ID
import os

print('Process (%s) start...' % os.getpid())
# Only works on Unix/Linux/Mac:
pid = os.fork()
if pid == 0:
    print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
    print('I (%s) just created a child process (%s).' % (os.getpid(), pid))

运行结果如下:

Process (876) start...
I (876) just created a child process (877).
I am child process (877) and my parent is 876.

multiprocessing

multiprocessing 模块就是跨平台版本的多进程模块

multiprocessing 模块提供了一个 Process 类来代表一个进程对象

from multiprocessing import Process
import os

# 子进程要执行的代码
def run_proc(name):
    print('Run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    
    # 新建一个子进程
    p = Process(target=run_proc, args=('test',))
    print('Child process will start.')
    
    # 调用 start() 方法启动进程
    p.start()
    # 阻塞当前进程,直到 p 进程执行完,再继续执行当前进程,通常用于进程间的同步
    p.join()
    print('Child process end.')

Pool

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

from multiprocessing import Pool
import os, time, random

def long_time_task(name):
    print('Run task %s (%s)...' % (name, os.getpid()))
    start = time.time()
    time.sleep(random.random() * 3)
    end = time.time()
    print('Task %s runs %0.2f seconds.' % (name, (end - start)))
    
if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    
    # 创建一个容量为4的进程池
    # Pool的默认大小是CPU的核数
    p = Pool(4)
    for i in range(5):
    
        # 放入子进程要处理的函数
        p.apply_async(long_time_task, args=(i,))
    print('Waiting for all subprocesses done...')
    
    # 调用join()之前必须先调用close(),调用close()之后就不能继续添加新的Process了
    p.close()
    
    # 对Pool对象调用join()方法会等待所有子进程执行完毕
    p.join()
    print('All subprocesses done.')

执行结果如下:

Parent process 669.
Waiting for all subprocesses done...
Run task 0 (671)...
Run task 1 (672)...
Run task 2 (673)...
Run task 3 (674)...
Task 2 runs 0.14 seconds.
Run task 4 (673)...
Task 1 runs 0.27 seconds.
Task 3 runs 0.86 seconds.
Task 0 runs 1.41 seconds.
Task 4 runs 1.91 seconds.
All subprocesses done.

task 0,1,2,3是立刻执行的,而task 4要等待前面某个task完成后才执行,最多同时执行4个进程

子进程

很多时候,子进程并不是自身,而是一个外部进程

我们创建了子进程后,还需要控制子进程的输入和输出

subprocess 模块可以让我们非常方便地启动一个子进程,然后控制其输入和输出

下面的例子演示了如何在Python代码中运行命令nslookup www.python.org,这和 cmd 直接运行的效果是一样的:

import subprocess

print('$ nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('Exit code:', r)

如果子进程还需要输入,则可以通过communicate()方法输入:

import subprocess

print('$ nslookup')

p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

output, err = p.communicate(b'set q=mx\npython.org\nexit\n')

print(output.decode('utf-8'))
print('Exit code:', p.returncode)

相当于在 cmd 执行命令 nslookup,然后手动输入:

set q=mx
python.org
exit

进程间通信

Python 的 multiprocessing 模块包装了底层的机制,提供了 QueuePipes 等多种方式来交换数据

from multiprocessing import Process, Queue
import os, time, random

# 写数据进程执行的代码:
def write(q):
    print('Process to write: %s' % os.getpid())
    for value in ['A', 'B', 'C']:
        print('Put %s to queue...' % value)
        q.put(value)
        time.sleep(random.random())

# 读数据进程执行的代码:
def read(q):
    print('Process to read: %s' % os.getpid())
    while True:
        value = q.get(True)
        print('Get %s from queue.' % value)
        
if __name__=='__main__':
    # 父进程创建Queue,并传给各个子进程:
    q = Queue()
    pw = Process(target=write, args=(q,))
    pr = Process(target=read, args=(q,))
    
    # 启动子进程pw,写入:
    pw.start()
    
    # 启动子进程pr,读取:
    pr.start()
    
    # 等待pw结束:
    pw.join()
    
    # pr进程里是死循环,无法等待其结束,只能强行终止:
    pr.terminate()

多线程

Python 的线程是真正的 Posix Thread ,而不是模拟出来的线程

Python的标准库提供了两个模块:_threadthreading_thread 是低级模块,threading 是高级模块,对 _thread 进行了封装

绝大多数情况下,我们只需要使用 threading 这个高级模块

任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程

Python 的 threading 模块有个 current_thread() 函数,它永远返回当前线程的实例。主线程实例的名字叫 MainThread ,子线程的名字在创建时指定

import time, threading

# 新线程执行的代码:
def loop():
    print('thread %s is running...' % threading.current_thread().name)
    n = 0
    while n < 5:
        n = n + 1
        print('thread %s >>> %s' % (threading.current_thread().name, n))
        time.sleep(1)
    print('thread %s ended.' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)

# 创建 Thread 实例
t = threading.Thread(target=loop, name='LoopThread')

t.start()
t.join()

print('thread %s ended.' % threading.current_thread().name)

Lock

多线程和多进程最大的不同在于

  • 多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响
  • 多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改

线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了

import time, threading

# 假定这是你的银行存款:
balance = 0

def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    # 1. 计算balance + n,存入临时变量中;2. 将临时变量的值赋给balance。
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(100000):
        change_it(n)

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

当代码正常执行时:

初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1     # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1     # balance = 0

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2     # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2     # balance = 0

结果 balance = 0

t1和t2交替运行时:

初始值 balance = 0

t1: x1 = balance + 5  # x1 = 0 + 5 = 5

t2: x2 = balance + 8  # x2 = 0 + 8 = 8
t2: balance = x2      # balance = 8

t1: balance = x1      # balance = 5
t1: x1 = balance - 5  # x1 = 5 - 5 = 0
t1: balance = x1      # balance = 0

t2: x2 = balance - 8  # x2 = 0 - 8 = -8
t2: balance = x2   # balance = -8

结果 balance = -8

所以,我们必须确保一个线程在修改balance(即公共变量)的时候,别的线程一定不能改

创建一个锁就是通过 threading.Lock() 来实现:

import time, threading

balance = 0
lock = threading.Lock()

def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    # 1. 计算balance + n,存入临时变量中;2. 将临时变量的值赋给balance。
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(100000):
        # 先要获取锁:
        lock.acquire()
        # 用try...finally来确保锁一定会被释放
        try:
            # 放心地改吧:
            change_it(n)
        finally:
            # 改完了一定要释放锁:
            lock.release()
            
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行

锁的坏处首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了

其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止

多核CPU

一个死循环线程会100%占用一个CPU,但启动N个死循环线程,却不会使把N核CPU的核心全部跑满

import multiprocessing

# 获取 CPU 核数
multiprocessing.cpu_count()

因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。

这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核

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

ThreadLocal

在多线程环境下,使用自己的局部变量比使用全局变量好,但是局部变量也有问题,就是在函数调用的时候,传递起来很麻烦

可以用全局 dict 存放所有的待传递对象,然后以 thread 自身作为 key 获得线程对应传递对象

ThreadLocal 对象不用查找 dict ,ThreadLocal 帮你自动做这件事:

import threading

# 创建全局ThreadLocal对象:
local_school = threading.local()

def process_student():
    # 获取当前线程关联的student:
    std = local_school.student
    print('Hello, %s (in %s)' % (std, threading.current_thread().name))
    
def process_thread(name):
    # 绑定ThreadLocal的student:
    local_school.student = name
    process_student()

t1 = threading.Thread(target= process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()

全局变量 local_school 就是一个 ThreadLocal 对象,每个 Thread 对它都可以读写 student 属性,但互不影响

可以把 local_school 看成全局变量,但每个属性如 local_school.student 都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal 内部会处理

ThreadLocal 最常用的地方就是为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源

进程 vs 线程

要实现多任务,通常我们会设计Master-Worker模式,Master负责分配任务,Worker负责执行任务

多进程模式最大的优点就是稳定性高,因为一个子进程崩溃了,不会影响主进程和其他子进程

多进程模式的缺点是创建进程的代价大,在Unix/Linux系统下,用 fork 调用还行,在Windows下创建进程开销巨大。另外,操作系统能同时运行的进程数也是有限的,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统连调度都会成问题

多线程模式通常比多进程快一点,但是也快不到哪去

多线程模式致命的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存

在Windows下,多线程的效率比多进程要高

线程切换

无论是多进程还是多线程,只要数量一多,效率肯定上不去

计算密集型 vs. IO密集型

是否采用多任务的第二个考虑是任务的类型。我们可以把任务分为计算密集型和IO密集型

计算密集型任务的特点是要进行大量的计算,消耗CPU资源。要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。对于计算密集型任务,最好用C语言编写。

IO密集型任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

异步IO

如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器

对应到Python语言,单线程的异步编程模型称为协程

分布式进程

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

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

服务进程 master 负责启动 Queue ,把 Queue 注册到网络上,然后往 Queue 里面写入任务:

import random, time, queue
from multiprocessing.managers import BaseManager

# 发送任务的队列:
task_queue = queue.Queue()
# 接收结果的队列:
result_queue = queue.Queue()

# 从BaseManager继承的QueueManager:
class QueueManager(BaseManager):
    pass
    
# 把两个Queue都注册到网络上, callable参数关联了Queue对象:
QueueManager.register('get_task_queue', callable=lambda: task_queue)
QueueManager.register('get_result_queue', callable=lambda: result_queue)

# 绑定端口5000, 设置验证码'abc':
manager = QueueManager(address=('', 5000), authkey=b'abc')

# 启动Queue:
manager.start()

# 获得通过网络访问的Queue对象:
task = manager.get_task_queue()
result = manager.get_result_queue()

# 放几个任务进去:
for i in range(10):
    n = random.randint(0, 10000)
    print('Put task %d...' % n)
    task.put(n)

# 从result队列读取结果:
print('Try get results...')
for i in range(10):
    r = result.get(timeout=10)
    print('Result: %s' % r)

# 关闭:
manager.shutdown()
print('master exit.')

在分布式多进程环境下,添加任务到Queue不可以直接对原始的 task_queue 进行操作,那样就绕过了 QueueManager 的封装,必须通过 manager.get_task_queue() 获得的 Queue 接口添加

在另一台机器上启动任务进程 worker(本机上启动也可以):

import time, sys, queue
from multiprocessing.managers import BaseManager

# 创建类似的QueueManager:
class QueueManager(BaseManager):
    pass
    
# 由于这个QueueManager只从网络上获取Queue,所以注册时只提供名字:
QueueManager.register('get_task_queue')
QueueManager.register('get_result_queue')

# 连接到服务器,也就是运行task_master.py的机器:
server_addr = '127.0.0.1'
print('Connect to server %s...' % server_addr)
# 端口和验证码注意保持与task_master.py设置的完全一致:
m = QueueManager(address=(server_addr, 5000), authkey=b'abc')
# 从网络连接:
m.connect()

# 获取Queue的对象:
task = m.get_task_queue()
result = m.get_result_queue()

# 从task队列取任务,并把结果写入result队列:
for i in range(10):
    try:
        n = task.get(timeout=1)
        print('run task %d * %d...' % (n, n))
        r = '%d * %d = %d' % (n, n, n*n)
        time.sleep(1)
        result.put(r)
    except Queue.Empty:
        print('task queue is empty.')
        
# 处理结束:
print('worker exit.')

Queue 之所以能通过网络访问,就是通过 QueueManager 实现的。由于 QueueManager 管理的不止一个 Queue ,所以,要给每个 Queue 的网络调用接口起个名字,比如 get_task_queue

authkey 是为了保证两台机器正常通信,不被其他机器恶意干扰。如果 worker 的authkey和 master 的authkey不一致,肯定连接不上

你可能感兴趣的:(Python 学习笔记10 - 进程 Process 和线程 Thread)