Python复习之多进程与多线程

本文介绍了Python中的多进程和多线程概念。进程是操作系统中执行的程序,通过fork或spawn创建,线程则在同一进程下共享上下文。Python的os模块和multiprocessing模块提供了进程支持,threading模块提供了线程支持。多进程和多线程可以提高执行效率,但需注意进程间通信和线程安全问题,如使用锁保护临界资源。然而,Python的GIL限制了多线程在多核CPU上的并行执行。

进程:进程就是操作系统中执行的一个程序,操作系统以进程为单位分配存储空间,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据,操作系统管理所有进程的执行,为它们合理的分配资源。进程可以通过fork或spawn的方式来创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此必须通过进程间通信机制(IPC,Inter-Process Communication)来实现数据共享,具体的方式包括管道、信号、套接字、共享内存区等。

线程:一个进程还可以拥有多个并发的执行线索,简单的说就是拥有多个可以获得CPU调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核CPU系统中,真正的并发是不可能的,因为在某个时刻能够获得CPU的只有唯一的一个线程,多个线程共享了CPU的执行时间。

Unix和Linux操作系统上提供了fork()系统调用来创建进程,调用fork()函数的是父进程,创建出的是子进程,子进程是父进程的一个拷贝,但是子进程拥有自己的PID。fork()函数非常特殊它会返回两次,父进程中可以通过fork()函数的返回值得到子进程的PID,而子进程中的返回值永远都是0。Python的os模块提供了fork()函数。由于Windows系统没有fork()调用,因此要实现跨平台的多进程编程,可以使用multiprocessing模块的Process类来创建子进程,而且该模块还提供了更高级的封装,例如批量启动进程的进程池(Pool)、用于进程间通信的队列(Queue)和管道(Pipe)等。

下面用一个下载文件的例子来说明使用多进程和不使用多进程到底有什么差别,先看看下面的代码。

# 普通单线程

from random import randint
from time import time, sleep

def download(filename):
    print("开始下载%s: " % filename)
    time_to_load = randint(3,6)
    sleep(time_to_load)
    print('%s下载完成! 耗费了%d秒' % (filename, time_to_load))

def main():
    start = time()
    download("Python从入门到吃土.avi")
    download("Java从学习到入土.jpg")
    end = time()
    print("总共耗费%.2f秒." % (end-start))

if __name__ == '__main__':
    main()

从上面的例子可以看出,如果程序中的代码只能按顺序一点点的往下执行,那么即使执行两个毫不相关的下载任务,也需要先等待一个文件下载完成后才能开始下一个下载任务,很显然这并不合理也没有效率。接下来我们使用多进程的方式将两个下载任务放到不同的进程中,代码如下所示。

from random import randint
from time import time, sleep
from multiprocessing import Process

def download(filename):
    print("开始下载%s: " % filename)
    time_to_load = randint(3,6)
    sleep(time_to_load)
    print('%s下载完成! 耗费了%d秒' % (filename, time_to_load))

def main():
    start = time()
    p1 = Process(target=download, args=("Python从入门到吃土.avi",))
    p1.start()
    p2 = Process(target=download, args=("Java从学习到入土.jpg",))
    p2.start()
    p1.join()
    p2.join()
    end = time()
    print("总共耗费%.2f秒." % (end-start))

if __name__ == '__main__':
    main()

在上面的代码中,我们通过Process类创建了进程对象,通过target参数我们传入一个函数来表示进程启动后要执行的代码,后面的args是一个元组,它代表了传递给函数的参数。Process对象的start方法用来启动进程,而join方法表示等待进程执行结束。运行上面的代码可以明显发现两个下载任务“同时”启动了,而且程序的执行时间将大大缩短,不再是两个任务的时间总和。

实现两个进程间的通信。我们启动两个进程,一个输出Ping,一个输出Pong,两个进程输出的Ping和Pong加起来一共10个。听起来很简单吧,但是如果这样写可是错的

from multiprocessing import Process
from time import sleep

counter = 0

def sub_taks(str):
    global counter
    while counter < 10:
        print(str, end='',flush=True)
        counter+=1
        sleep(0.01)

def main():
    p1 = Process(target=sub_taks, args=("Ping",))
    p2 = Process(target=sub_taks, args=("Pong",))
    p1.start()
    p2.start()

if __name__ == '__main__':
    main()

看起来没毛病,但是最后的结果是Ping和Pong各输出了10个,Why?当我们在程序中创建进程的时候,子进程复制了父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个counter变量,所以结果也就可想而知了。要解决这个问题比较简单的办法是使用multiprocessing模块中的Queue类,它是可以被多个进程共享的队列

from multiprocessing import Process
from multiprocessing import Queue
from time import sleep

def producer(sequence, output_q):
    for num in sequence:
        output_q.put(num)

def consumer(intput_q,str):
    while True:
        num = intput_q.get()
        if num == None:
            break
        print(str, end='',flush=True)
        sleep(0.01)

def main(q):
    p1 = Process(target=consumer, args=(q, "Ping",))
    p2 = Process(target=consumer, args=(q, "Pong",))
    p1.start()
    p2.start()

if __name__ == '__main__':
    q = Queue()
    sequence = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    producer(sequence, q)
    q.put(None)
    q.put(None)
    main(q)

在Python早期的版本中就引入了thread模块(现在名为_thread)来实现多线程编程,然而该模块过于底层,而且很多功能都没有提供,因此目前的多线程开发我们推荐使用threading模块,该模块对多线程编程提供了更好的面向对象的封装。我们把刚才下载文件的例子用多线程的方式来实现一遍。

from random import randint
from time import time, sleep
from threading import Thread

def download(filename):
    print("开始下载%s: " % filename)
    time_to_load = randint(3,6)
    sleep(time_to_load)
    print('%s下载完成! 耗费了%d秒' % (filename, time_to_load))

def main():
    start = time()
    t1 = Thread(target=download, args=("Python从入门到吃土.avi",))
    t2 = Thread(target=download, args=("Java从学习到入土.jpg",))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    end = time()
    print("总共耗费%.2f秒." % (end-start))

if __name__ == '__main__':
    main()

我们可以直接使用threading模块的Thread类来创建线程,我们可以从已有的类创建新类,因此也可以通过继承Thread类的方式来创建自定义的线程类,然后再创建线程对象并启动线程。代码如下所示。

from random import randint
from time import time, sleep
from threading import Thread

class down(Thread):
    def __init__(self,filename):
        super().__init__()
        self._filename = filename

    def run(self):
        print("开始下载%s: " % self._filename)
        time_to_load = randint(3,6)
        sleep(time_to_load)
        print('%s下载完成! 耗费了%d秒' % (self._filename, time_to_load))

def main():
    start = time()
    t1 = down("Python从入门到吃土.avi")
    t2 = down("Java从学习到入土.jpg")
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    end = time()
    print("总共耗费%.2f秒." % (end-start))

if __name__ == '__main__':
    main()

因为多个线程可以共享进程的内存空间,因此要实现多个线程间的通信相对简单,大家能想到的最直接的办法就是设置一个全局变量,多个线程共享这个全局变量即可。但是当多个线程共享同一个变量(我们通常称之为“资源”)的时候,很有可能产生不可控的结果从而导致程序失效甚至崩溃。如果一个资源被多个线程竞争使用,那么我们通常称之为“临界资源”,对“临界资源”的访问需要加上保护,否则资源会处于“混乱”的状态。下面的例子演示了100个线程向同一个银行账户转账(转入1元钱)的场景,在这个例子中,银行账户就是一个临界资源,在没有保护的情况下我们很有可能会得到错误的结果。

from time import time, sleep
from threading import Thread

class Account(object):
    def __init__(self):
        self._balance = 0

    def deposit(self, money):
        # 计算存款后的余额
        # self._balance = self._balance+money
        new_balance = self._balance+money
        sleep(0.01)
        self._balance = new_balance

    @property
    def banlance(self):
        return self._balance

class AddMoneyThread(Thread):
    def __init__(self,account, money):
        super().__init__()
        self._account = account
        self._money = money

    def run(self):
        self._account.deposit(self._money)

def main():
    account = Account()
    thread = []
    for _ in range(100):
        t = AddMoneyThread(account, 1)
        thread.append(t)
        t.start()
    # 等所有线程执行完
    for t in thread:
        t.join()
    print('账户余额为: ¥%d元' % account.banlance)

if __name__ == '__main__':
    main()

100个线程分别向账户中转入1元钱,结果居然远远小于100元。之所以出现这种情况是因为我们没有对银行账户这个“临界资源”加以保护,多个线程同时向账户中存钱时,会一起执行到new_balance = self._balance + money这行代码,多个线程得到的账户余额都是初始状态下的0,所以都是0上面做了+1的操作,因此得到了错误的结果。在这种情况下,“锁”就可以派上用场了。我们可以通过“锁”来保护“临界资源”,只有获得“锁”的线程才能访问“临界资源”,而其他没有得到“锁”的线程只能被阻塞起来,直到获得“锁”的线程释放了“锁”,其他线程才有机会获得“锁”,进而访问被保护的“临界资源”。下面的代码演示了如何使用“锁”来保护对银行账户的操作,从而获得正确的结果。

from time import time, sleep
from threading import Thread, Lock

class Account(object):
    def __init__(self):
        self._balance = 0
        self._lock = Lock()

    def deposit(self, money):
        # 计算存款后的余额
        self._lock.acquire()
        # 先获取锁才能执行代码
        try:
            new_balance = self._balance+money
            sleep(0.01)
            self._balance = new_balance
        finally:
            # 不管怎样,最终释放锁
            self._lock.release()

    @property
    def banlance(self):
        return self._balance

class AddMoneyThread(Thread):
    def __init__(self,account, money):
        super().__init__()
        self._account = account
        self._money = money

    def run(self):
        self._account.deposit(self._money)

def main():
    account = Account()
    thread = []
    for _ in range(100):
        t = AddMoneyThread(account, 1)
        thread.append(t)
        t.start()
    # 等所有线程执行完
    for t in thread:
        t.join()
    print('账户余额为: ¥%d元' % account.banlance)

if __name__ == '__main__':
    main()

比较遗憾的一件事情是Python的多线程并不能发挥CPU的多核特性,这一点只要启动几个执行死循环的线程就可以得到证实了。之所以如此,是因为Python的解释器有一个“全局解释器锁”(GIL)的东西,任何线程执行前必须先获得GIL锁,然后每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行,这是一个历史遗留问题,但是即便如此,就如我们之前举的例子,使用多线程在提升执行效率和改善用户体验方面仍然是有积极意义的。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值