更多优质内容
请关注公众号

Python多线程和多进程(六)  多进程编程和同步-张柏沛IT博客

正文内容

Python多线程和多进程(六) 多进程编程和同步

栏目:Python 系列:Python多线程和多进程系列 发布时间:2020-04-11 14:48 浏览量:3341

多进程编程

多进程和多线程对比:
1.由于python有GIL锁,而且是一个进程管理着1把GIL锁,所以多线程无法使用多核,即同一时刻只能一个线程在运行。
而多进程编程可以使用多核。意味着,多进程可以并行,同一时刻多进程可以使用多个CPU从而同时运行,而多线程不能做到真正的同时运行。

一句话:python中的多线程只能并发不能并行,多进程则可以并行(前提是你的电脑是多核的)

2.由于多线程只能并发不能并行,所以适合多IO操作,少CPU操作的任务,例如爬虫,磁盘读写等任务。
多进程相反,适合少IO操作,多CPU操作的任务,例如纯计算的任务。
原因是IO操作会存在等待,线程可以在等待的时候让出CPU给其他线程工作,以达到让整个程序一直都在干活不闲着。
但是如果是耗CPU的操作,则几乎不存在等待阻塞的情况,每一个线程不会因为阻塞而让出CPU,而是因为时间片用完而让出CPU。这种情况下,多线程会比单线程更慢,因为多了线程间的切换。

而对于多进程而言,由于可以并行,多个进程可以同时完成多个耗CPU的操作,节省时间。举个例子,我现在想完成两个比较庞大的运算:A和B任务。就是就可以生成两个子进程,一个计算A任务,一个计算B任务。A和B任务是并行的,比并发更快。但是有种理解是错误的:认为多进程可以一起完成任务A的运算,让A运算的时间减半,这是不行的。
而对于IO操作,当遇到等待的时候,进程会将CPU让出给其他程序的进程,这段期间这个进程还是什么都做不了。而且多进程的切换耗时更多,进程消耗的资源更多(如内存,fork一个进程是要拷贝一份父进程的内存的),因此还不如用多线程。

3.多线程的切换比多进程的切换的损耗要少很多,多进程的切换更慢。线程是轻量级的,进程是重量级的。


PS:在windows下,python多进程的必须在 if __name__=="__main__":下运行,否则会报错。在Linux则不存在这个问题。

1.多进程之间不共享数据

例子:

from multiprocessing import Process

num=10
def task1:
    global num
    num+=5
    print("子进程1的num:%d" % num)
    
def task2:
    global num
    num+=10
    print("子进程2的num:%d" % num)
    
p1 = Process(target=task1)
p2 = Process(target=task2)
p1.start()      # p1.pid 可以获取其进程id
p2.start()
p1.join()
p2.join()
print("主进程的num:%d" % num)

结果得到:
子进程1的num:15
子进程2的num:20
主进程的num:10

从这里可以知道三个进程之间的num是不共享的。
如果是共享的得到应该是 15 25 25

原因详细分析:
因为主进程创建一个子进程,底层会执行fork(),系统会将主进程完全拷贝一份放在另一个空间,包括拷贝主进程的PCB,内存,数据等。
所以上面的程序中生成了2个子进程,就会开辟两块空间,并将主进程的内容复制两份分别放在两个空间中。加上主进程自己,就有三块空间。
三块空间里面各有一个num,这三个num变量互不干扰。所以每个进程自己操作自己的num,不会影响其他两个进程的num

多进程和多线程的很多接口是一样的,如start(),join()等方法。
Process也可以像Thread一样被其他类继承。

 

2.使用进程池编程

之前说过,进程池和线程池才是进程线程的最佳实践。

# coding=utf-8

from multiprocessing import Pool

def task(num):
    res = 0
    for i in range(num):
        res+=i

    return res


if __name__=="__main__":
    # 创建一个进程池
    pool = Pool()   # 默认生成和核数相同的进程数
    task1 = pool.apply_async(task,args=(10000,))    # 相当于线程池中的submit,此时子进程已经在运行
    task2 = pool.apply_async(task,args=(20000,))

    pool.close()    # 关闭进程池,不让进程池接收新的任务
    pool.join()     # 等待所有子进程运行完,必须执行pool.close()才能调用join()否则报错

    print(task1.get())  # 获取结果,这个方法可以阻塞父进程直到子进程任务执行完,调用get()的时候无需调用join,除非想一次性获取所有任务的结果
    print(task2.get())
    


    

Pool的源码这里不再贴出,有时间可以查看Pool的源码。


可以使用 imap 方法,这个类似于线程池 ThreadPoolExecutor 中的map() 方法:

if __name__=="__main__":
    # 创建一个进程池
    pool = Pool()   # 默认生成和核数相同的进程数

    for result in pool.imap(task,[2000000,200000]):
        print(result)


        
结果:
(过了5秒)
199999990000000
19999900000

imap_unordered 方法,和imap类似,但是imap_unordered会先返回先执行完毕的任务的结果,而imap则是等所有任务执行完之后,按传入任务的顺序返回任务的结果。

if __name__=="__main__":
    # 创建一个进程池
    pool = Pool()   # 默认生成和核数相同的进程数

    for result in pool.imap_unordered(task,[2000000,200000]):
        print(result)

结果:
(过了1秒)
19999900000
(过了4秒)
199999990000000

除了 multiprocessing.Pool 之外,还可以使用 concurrent.futures.ProcessPoolExecutor 进程池,它和 ThreadPoolExecutor 线程池的方法是一致的


3. 进程间通信

进程间通信的三种方式:队列Queue,管道Pipe和共享内存Manager或者 sharedctypes


3-1 共享内存

学过多进程基础的朋友都知道,共享内存是进程间通信方式中最简单也最快的方式。

共享内存的原理:
多个进程的虚拟内存(就是页)会映射到同一块物理内存,多个进程可以共享这一块物理内存的数据。
多进程可以通过共享内存来共享数据,但是系统内核不负责多进程对共享内存中数据的修改和访问进行同步。意思是,如果多个进程并发或者并行的修改共享内存中的同一个数据,很可能会出现数据不一致。

multiprocessing 提供了两种创建共享内存的方式 Manager和sharedctypes

Manager效率较低,但支持远程共享内存。
sharedctypes效率较高,快Manager两个数量级,在多进程访问时与普通内存访问相当
例子:

from multiprocessing import Process

def task1:
    global num
    num+=5
    print("子进程1的num:%d" % num)
    
def task2:
    global num
    num+=10
    print("子进程2的num:%d" % num)
    
if __name__=="__main__":
    num = 10
    p1 = Process(target=task1)
    p2 = Process(target=task2)
    p1.start()      # p1.pid 可以获取其进程id
    p2.start()
    p1.join()
    p2.join()
    print("主进程的num:%d" % num)

 

结果得到:
子进程1的num:15
子进程2的num:20
主进程的num:10

这个例子告诉我们,多进程不会共享数据。


但是如果 我们将变量 num 存放到共享内存中,那么主进程,p1和p2进程就不会各有1份num变量,而是只有唯一的1份num变量存在共享内存中:

# coding=utf-8

from multiprocessing import Manager
from multiprocessing import Process

def task1(num):
    num.value+=5
    print("子进程1的num:%d" % num.value)

def task2(num):
    num.value+=10
    print("子进程2的num:%d" % num.value)

if __name__=="__main__":
    manager = Manager()     # 定义一个共享内存对象
    num = manager.Value("abc",10)
    # print(num.value)
    p1 = Process(target=task1,args=(num,))
    p2 = Process(target=task2,args=(num,))
    p1.start()      
    p2.start()
    p1.join()
    p2.join()
    print("主进程的num:%d" % num.value)


    

共享内存不保证进程的同步,所以多个进程如果操作同一个共享内存的同一个资源或者变量时要加锁:

# coding=utf-8

from multiprocessing import Manager
from multiprocessing import Process

def task1(num,lock):
    for i in range(10000):
        lock.acquire()
        num.value+=1
        lock.release()

def task2(num,lock):
    for i in range(10000):
        lock.acquire()
        num.value-=1
        lock.release()

if __name__=="__main__":
    manager = Manager()     # 定义一个共享内存对象
    num = manager.Value("abc",0)
    lock = manager.Lock()   # 获取锁
    p1 = Process(target=task1,args=(num,lock))
    p2 = Process(target=task2,args=(num,lock))
    p1.start()      
    p2.start()
    p1.join()
    p2.join()
    print("主进程的num:%d" % num.value)
    

最后得到的num为0

PS:这里加的锁必须是 Manager 共享对象中实例化的锁,而不能是 Thread 模块中的锁,原因很简单,Thread模块的锁是一个共享变量,但是多进程是不共享变量的,所以如果使用Thread中的锁,这个锁会在所有子进程中都复制一份,那么这个锁就不是1把锁而是3把锁了,就起不到保护进程安全的作用。
从Manager实例化的锁是存放在共享内存,主进程,p1,p2进程共享1把锁。


可以这么理解: 
从Manager对象中创建出来的变量,锁,条件变量,事件对象,信号量,队列都是放在共享内存中的。
多进程编程只能使用共享内存中的锁,条件变量和队列等。

 

最后需要注意:

共享内存由系统调用创建,不属于任何一个进程,任何一个进程根据共享内存id都能访问该共享内存。即使使用共享内存的进程关闭,共享内存仍可以保留在系统中。

 

3-2 队列Queue

# coding=utf-8

from multiprocessing import Manager
from multiprocessing import Process,Queue

def task1(queue):
    queue.put("a")
    print("queue put a")

def task2(queue):
    res = queue.get()
    print("queue get %s" % res)

if __name__=="__main__":
    queue = Queue()     # 或者使用 manager = Manager(); queue=manager.Queue()这个Queue
    p1 = Process(target=task1,args=(queue,))
    p2 = Process(target=task2,args=(queue,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    


PS:
1. 多进程使用队列Queue进行通信的时候,不能使用 from queue import Queue 中的Queue(否则会报错),只能使用 from multiprocessing import Queue 中的Queue 或者是 Manager 中的Queue

2.如果使用进程池 multiprocessing.Pool 创建的进程要使用Queue通信,则只能使用 Manager 中的Queue

Queue是进程安全的,里面加了锁,所以不用担心队列被多个进程竞争使用时被改乱。

 


3-3 Pipe管道

pipe只能用于两个进程间的通信。它有点像socket,会在两个进程之间建立一个连接,连接的两端会打开两个口,类似于端口。数据的通信和交换通过这个连接进行。

和queue不同的是,queue是可以让多个进程进行通信,而pipe只能用于两个进程之间通信。
queue的性能低,因为里面加了锁;pipe没有加锁,而是使用了类似socket这样的技术在进程间建立了一个连接。

# coding=utf-8

from multiprocessing import Manager
from multiprocessing import Process,Pipe
from time import sleep

def task1(conn):
    res = conn.recv()   # 会阻塞进程,直到接收到消息
    print("task1 recv ",res)
    conn.send("Got it")

def task2(conn):
    # sleep(5)
    conn.send("abc")
    print("task2 send abc")

    res = conn.recv()
    print(res)

if __name__=="__main__":
    # queue = Queue()
    conn1,conn2 = Pipe()    # Pipe()返回两个对象,这两个对象有点像套接字,两个对象都可以进行消息的发送和接收
    p1 = Process(target=task1,args=(conn1,))
    p2 = Process(target=task2,args=(conn2,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    


    
    
结果:
task2 send abc
task1 recv  abc
Got it




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > Python多线程和多进程(六) 多进程编程和同步

热门推荐
推荐新闻