Python之并发编程

一、概述

白话:一个进程里面的子任务称为线程,所以一个进程至少有一个线程;

进程:一个具有独立功能的程序,关于某个数据集合的一次运动活动;

线程:是操作系统能够进行运算调度的最小单位,被包含在进程中,是进程的实际运作单位;

python 协程 并发数 python线程并发_开发语言

多任务的几种模式如下:

1、启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务(多进程)

2、启动一个进程,在一个进程内启动多个线程,多个线程也能一块执行多个任务(多线程)

3、启动多进程和多线程,一般情况下不使用这个,过于复杂;

二、多线程简单实现

Python的标准库提供了两个模块:_thread和threading,前者是低级模块,后者是高级模块,一般情况下使用threading即可;

线程创建的两种方式:

1、方法包装;

2、类包装;

代码实现:

# 方法包装实现
from threading import Thread
from time import sleep


# 创建一个简单的方法
def run(name):
    print(f"Thread: {name} start")
    sleep(3)


# 创建线程
t1 = Thread(target=run, args=('t1', ))
t2 = Thread(target=run, args=('t2', ))

# 正常运行
run("t1")
run("t2")

# 多线程运行
t1.start()
t2.start()

通过有无开启多线程模拟程序,发现多线程下是同时执行的,大大提高了速度;

# 类实现
class MyThread(Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name

    # 注意这里只能写run方法
    def run(self):
        print(f"Thread: {self.name} start")
        sleep(3)

# 创建线程
t1 = MyThread('t1')
t2 = MyThread('t2')
# 启动线程
t1.start()
t2.start()

类实现多线程注意其中的方法名只能用run;

三、主子线程和join的使用

这里有一个概念,程序执行main程序是一个主线程,如果我们不增加等待线程执行完的操作,主线程和子线程是并行执行的,也就是方法还没执行完就会继续往后执行了;

可以由以下代码看下当前主线程:

print(threading.currentThread())

在实际项目中,往往需要当前子线程执行完再继续往后执行,这里就需要加一个等待了;

# 创建线程
t1 = MyThread('t1')
t2 = MyThread('t2')
# 启动线程
t1.start()
t2.start()
# 等待子线程执行完再往下执行
t1.join()
t2.join()

注意:

等待的位置需要写在所有子线程启动以后;

拓展:

能否使用循环来创建多个线程呢?

thread_list = []
for i in range(10):
	t = Thread(target=run, arfgs=(f't{i+1}', ))
	t.start()
	thread_list.append(t)
for t in thread_list:
	t.join()

这就是用循环的方式创建了基于方法的多线程的参考代码;

四、守护线程

一般在主线程中断时,子线程是没有被中断的,这就会导致程序执行完后,子线程还在运行;

需要通过一个设置将子线程设置为守护线程;

t.setDaemon(True)
t.start()

注意设置需要在线程启动之前,也就是start之前;

五、线程锁

问题:如果运算量过大的时候,并行计算可能会出现计算错误的问题;

from threading import Thread, Lock

def func1(name):
    lock.acquire()		# 获取锁
    global count
    for i in range(1000000):
        count += 1
    lock.release()		# 计算完释放锁


if __name__ == "__main__":
    count = 0
    t_list = []
    lock = Lock()		# 后续添加的初始化锁的对象
    for i in range(10):
        t = Thread(target=func1,args=(f't{i+1}', ))
        t.start()
        t_list.append(t)
    for i  in t_list:
        t.join()

    print(count)

上面程序由于计算量过大,运行时打印出7961520等错误答案,这就是线程的不安全性引起的;

通过加入锁的机制,可以有效避免这个问题,上面关于lock的语句就是锁的添加;

锁的原理:

首先来看一下Python多线程内部的执行关系:

python 协程 并发数 python线程并发_python 协程 并发数_02

说明:其中GIL是一个Python解释器(CPython)时自带的,做为一个全局锁;他有一个缺陷就是,会有一定的时间限制,如果超出这个时间限制则解除锁,这也就是为什么计算量大的时候数据安全发生问题,所以需要我们自己添加锁;

注意:

1、如果想保证同个数据安全,必须使用同一把锁;

2、如果使用锁,程序会变成串行,所以需要在合适的地方再加锁;

3、也可以使用with来使用锁;

拓展:

如果程序出现死锁怎么办?

死锁也就是需要当前锁时,已经被另一个程序使用了并且还没释放,所以程序就类似for循环一样不断等待卡死;

解决办法:将我们的锁改成逻辑锁;

from threading import RLock

将之前的Lock(锁、同步锁、互斥锁)改成RLock(递归锁),这样可以避免死锁的问题;

六、信号量

作用:信号量可以用来设置指定个数的线程,也就是每次可以有几个线程进行运行;

下面来看一个简单的程序:

from time import sleep
from threading import Thread
from threading import BoundedSemaphore


def search(num):
    lock.acquire()
    print(f'第{num}个人完成安检!!!')
    sleep(2)
    lock.release()


if __name__ == "__main__":
    lock = BoundedSemaphore(3)  # 设置每次只能安检三个人
    for i in range(10):
        t = Thread(target=search, args=(f'{i+1}', ))
        t.start()

这个程序通过信号量,设置了每次只能同时执行三个线程;

七、事件

Event()可以创建一个事件管理标志,该标志默认为False,主要有以下四种返回方法可以调用:

  • event.wait(timeout=None):
    调用该方法的线程会被阻塞,如果设置了timeout参数,超时后会停止阻塞线程继续执行;
  • event.set():
    将event的标志设置为True,调用wait方法的所有线程将被唤醒;
  • event.clear():
    将event的标志设置为False,调用wait方法的所有线程将被阻塞;
  • event.is_set():
    判断event的标志是否为True;

下面演示一个门禁系统的程序:

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

state = 0
def door():
    global state
    while True:			# 一直循环
        if even.is_set():	# 一开始门是开着的
            print('门开着,可以通行~')
            sleep(1)
        else:
            print('门关了~请刷卡!')
            state = 0
            even.wait()		# 这里将该线程进行阻塞
        if state > 3:
            print('超过 3 秒,门自动关门')
            even.clear()
        state += 1
        sleep(1)


def person():
    global state
    n = 0
    while True:			# 一直循环
        n += 1
        if even.is_set():
            print('门开着:{}号进入'.format(n))
        else:
            even.set()	# 刷卡将阻塞线程释放
            state = 0
            print('门关着,{}号人刷卡进门'.format(n))
        sleep(randint(1, 10))


if __name__ == '__main__':
    even = Event()		# 设置事件这个对象
    even.set()			# 将事件设置成True状态
    t1 = Thread(target=door)
    t1.start()
    t2 = Thread(target=person)
    t2.start()

事件可以理解成一个控制线程阻塞条件和释放条件的变量,主要是记住上面四个接口函数;

八、队列

线程安全的概念:

多线程编程时的计算机程序代码中的一个概念,在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常执行,不会出现数据污染;

队列好处:

1、安全

2、解耦

3、提高效率

实际上Python内部已经封装了这个队列的模块 —— Queue;

其中包含了先进先出队列,后进先出队列,优先队列等数据结构,内部都实现了锁机制,能够直接在多线程中使用;

可以参考以下文章进行代码调试:

九、生产者消费者模式

本质上生产者消费者模式是通过一个容器解决二者的强耦合问题的,也就是上面提到的队列;利用线程阻塞的原理,实现一个缓冲区的功能,平衡生产者和消费者的处理能力;

代码实现:

from threading import Thread
from queue import Queue
from time import sleep


def producer():
    num = 1
    while True:
        print(f"生产了{num}号猫猫")
        mq.put(f"{num}号猫猫")
        num += 1
        sleep(1)


def consumer():
    while True:
        print("购买了{}".format(mq.get()))
        sleep(2)


if __name__ == "__main__":
    # 共享数据的容器
    mq = Queue(maxsize=10)
    # 创建生产者线程
    t1 = Thread(target=producer)
    # 创建消费者线程
    t2 = Thread(target=consumer)
    # 开始工作
    t1.start()
    t2.start()

实际上也就是使用了队列模拟了生产者消费者的模式,其中内部也实现了线程安全的策略;

十、进程的创建

使用Python中的multiprocessing中的Process模块;

也是有两种实现方式:方法和类实现;

下面是基于函数实现进程的代码:

from multiprocessing import Process
from time import sleep


def func(name):
    print(f"{name}进程开始...")
    sleep(2)
    print(f"{name}进程结束...")


if __name__ == '__main__':
    p1 = Process(target=func, args=('p1', ))
    p2 = Process(target=func, args=('p2', ))
    p1.start()
    p2.start()

下面是基于类实现进程的代码:

from multiprocessing import Process
from time import sleep


class MyProcess(Process):
    def __init__(self, name):
        super(MyProcess, self).__init__()
        self.name = name
    
    def run(self):
        print(f"{self.name}进程开始...")
        sleep(2)
        print(f"{self.name}进程结束...")

if __name__ == '__main__':
    p1 = MyProcess('p1')
    p2 = MyProcess('p2')
    p1.start()
    p2.start()

实际上和线程的创建基本一致,只是换了一个模块而已;

十一、进程的通信

使用进程的优点:

1、可以使用计算机多核,进行任务的并发执行,提高执行效率;

2、运行时不受其他进程影响,创建方便;

3、由于进程间是独立的,数据安全性高;

使用进程的缺点:

进程的创建和销毁消耗的系统资源较多;

进程间的通信主要有以下两种方法:

1、使用multiprocessing模块下的Queue类,提供了进程间通信的多种方法;

2、Pipe,又称为管道,常用于实现两个进程之间的通信;

Queue实现进程通信的代码实现:

from multiprocessing import Process, Queue
import os

def fun(name, mq):
    print('进程ID {}, 获取数据: {}'.format(os.getpid(), mq.get()))

if __name__ == '__main__':
    print('主进程ID:{}'.format(os.getpid()))
    mq = Queue()
    mq.put('test')
    p1 = Process(target=fun, args=('p1', mq))
    p1.start()
    p1.join()

可以看出两个进程之间是有实现通信的,p1进程能接收到主进程容器中的数据;

原理上是把A进程中的数据copy了一份,然后发送给了B进程;

Pipe实现进程通信的代码:

from multiprocessing import Process, Pipe
import os

def fun(name, con):
    print('进程ID {}, 获取数据: {}'.format(os.getpid(), con.recv()))
    con.send("Hi!")

if __name__ == '__main__':
    print('主进程ID:{}'.format(os.getpid()))
    con1, con2 = Pipe()
    p1 = Process(target=fun, args=('p1', con1))
    p1.start()
    con2.send("Hello!")
    p1.join()
    print(con2.recv())

十二、Manager的使用

提供了进程间数据共享的方法,类似于一个容器可以在进程间共享并确保数据安全,特点也是在于支持多种数据类型;

代码实现:

from multiprocessing import Process, Manager
import os


def fun(name, m_list, m_dict):
    print('进程ID {}, 获取数据: {}'.format(os.getpid(), m_list))
    print('进程ID {}, 获取数据: {}'.format(os.getpid(), m_dict))
    m_list.append("two")
    m_dict['name'] = 'dabao'


if __name__ == '__main__':
    print('主进程ID:{}'.format(os.getpid()))
    with Manager() as mag:
        m_list = mag.list()     # 创建共享的数组对象
        m_dict = mag.dict()     # 创建共享的字典对象
        m_list.append("one")
        p1 = Process(target=fun, args=('p1', m_list, m_dict))
        p1.start()
        p1.join()
        print(m_list)
        print(m_dict)

相对于线程中的Queue类,进程的共享容器的方法更加便捷,使用的结构也是Python中的常用结构;

个人理解是将数组、字典进行了封装,加入了线程安全和通信的方法;

十三、进程池

多进程的方式存在一个问题,也就是创建和销毁进程时耗时比较久,也比较消耗内存资源;

为了解决上面这个问题,提出了进程池的概念,用于管理多进程,进程池可以提供指定数量的进程给用户使用,如果池未满则会创建新的进程,池满则会等待进程执行完空闲,类似于线程中的信号量的概念;

进程池的优点:

1、提高效率,节省开辟空间和销毁进程的时间;

2、节省内存空间;

代码实现:

from multiprocessing import Pool
import os
from time import sleep


def fun(name):
    print(f"当前进程的ID:{os.getpid()},{name}")
    sleep(2)
    return name

def func2(args):
    print(args)


if __name__ == '__main__':
    pool = Pool(5)
    # pool.apply(fun, args=('t1',))           # 串行添加进程任务
    # 下面callback参数表示接收子进程的返回值并传递给func2这个函数
    pool.apply_async(fun, args=('t1',), callback=func2)     # 并行添加进程任务
    pool.apply_async(fun, args=('t2',))
    pool.apply_async(fun, args=('t3',))
    pool.apply_async(fun, args=('t4',))
    pool.apply_async(fun, args=('t5',))
    pool.apply_async(fun, args=('t6',))
    pool.apply_async(fun, args=('t7',))
    pool.apply_async(fun, args=('t8',))

    pool.close()        # 关闭进程池
    pool.join()         # 回收进程池

实际执行可以看出,是先执行5个进程任务,再执行剩下3个;

也可以用map进行批量进程调用,代码如下:

from multiprocessing import Pool
import os
from time import sleep


def fun(name):
    print(f"当前进程的ID:{os.getpid()},{name}")
    sleep(2)
    return name


if __name__ == '__main__':
    with Pool(5) as pool:
        args = pool.map(fun, ("t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"))
        for a in args:
            print(a)

十四、协程

协程又称为微线程、纤程,简单来说就是一种用户态的轻量级线程;

协程的作用:

在执行函数A和函数B时,可以随时中断切换,协程是作用在一个线程中的,其执行过程很像多线程;

协程的目的:

由于多线程之前切换是需要时间的,而在单线程控制多个任务时,一旦出现一个任务遇到IO阻塞时,可以切换去执行另一个任务,从而保证该线程处于最佳的执行效率,这样的方式也就是协程;需要明确一点,线程是由CPU控制的,而协程是由程序自身控制的;

协程的标准:

必须在只有一个单线程中实现并发:

  • 修改共享数据不需要加锁(因为是在一个线程中)
  • 用户程序需要自己保存多个控制流的上下文栈
  • 一个协程遇到IO操作后自动切换到其他协程

python 协程 并发数 python线程并发_并发编程_03

这个图很好的表示了进程、线程、协程之间的关系;

协程的优点:

1、自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,更加轻量级;

2、无需锁操作,内部数据是共享的;

3、单线程内就可实现并发的效果,最大程度的利用CPU,成本低

(一个CPU支持上万协程都不是问题,更适合用于高并发)

协程的缺点:

1、无法利用多核资源,因为协程本质是一个单线程,需要配合进程才能运行在多CPU上;

2、进行阻塞操作时会阻塞整个程序;

协程的代码实现:

1、使用yield关键字,yield可以保存状态,构造生成器的方式,可以实现线程中任务的切换;

def func1():
    for i in range(11):
        print(f"A端第{i}次打印...")
        yield


def func2():
    g = func1()
    next(g)
    for k in range(10):
        print(f"B端第{k}次打印...")
        next(g)


if __name__ == '__main__':
    func2()

使用yield可以实现线程内任务切换,但实际上并不能提升效率,实际上协程需要使用别的方法;

2、使用greenlet实现协程

使用yied生成器实现协程的方式比较麻烦,使用greenlet模块更加方便;

from greenlet import greenlet

def playA(name):
    print(f'{name}: 我要干饭!!')
    g2.switch('主子')
    print(f'{name}: 我要出去玩!!')
    g2.switch()


def playB(name):
    print(f'{name}: 天天就知道吃!!')
    g1.switch()
    print(f'{name}: 天天就知道玩!!')


if __name__ == '__main__':
    g1 = greenlet(playA)
    g2 = greenlet(playB)
    g1.switch('大宝')

缺点:实际上还是需要人为的在程序中进行切换;

3、使用Gevent模块实现协程

优点在于不需要人为去定义切换位置,会自动识别IO部分并进行切换;

from gevent import monkey
monkey.patch_all()              #必须放到被打补丁者的前面,如 time,socket 模块之前
import gevent
from time import sleep


def playA(name):
    print(f'{name}: 我要干饭!!')
    sleep(2)
    print(f'{name}: 我要出去玩!!')


def playB(name):
    print(f'{name}: 天天就知道吃!!')
    sleep(2)
    print(f'{name}: 天天就知道玩!!')


if __name__ == '__main__':
    # 创建协程
    g1 = gevent.spawn(playA, '大宝')
    g2 = gevent.spawn(playB, '主子')

    # 启动协程
    g1.join()
    g2.join()

4、async实现协程,这个模块是使用事件循环驱动实现并发;

import asyncio


async def func1():
    for i in range(5):
        print("Python 真好玩!")
        await asyncio.sleep(1)


async def func2():
    for i in range(5):
        print("使用协程能够更高效!")
        await asyncio.sleep(2)


# 创建协程对象
g1 = func1()
g2 = func2()
loop = asyncio.get_event_loop()     # 获取事件循环
loop.run_until_complete(asyncio.gather(g1, g2))    # 监听事件循环
loop.close()        # 关闭事件

当然asyncio这个模块还有更高级的用法,但目前还没涉及到,就先不进行拓展;

十五、总结

1、串行、并行、并发的区别:

python 协程 并发数 python线程并发_python 协程 并发数_04

并行:指的是任务数小于等于CPU核数,即任务真的是一起执行的;

并发:指的是任务数大于CPU核数,通过操作系统的各种任务调度算法,实现多个任务"一起"执行,但实际上有些任务不在执行,只是由于切换任务速度快,看起来像一起执行;

2、进程、线程和协程的区别

python 协程 并发数 python线程并发_python 协程 并发数_05

  • 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  • 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  • 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间和资源;
  • 线程的上下文切换要比进程上下文切换快得多;

3、进程、线程和协程的特点

进程:拥有自己独立的堆和栈,既不共享堆也不共享栈,进程由操作系统调度,进程切换需要资源很大,效率很低;

线程:拥有自己独立的栈和共享的堆,标准线程由操作系统调度,线程切换需要的资源一般,效率一般;

协程:拥有自己独立的栈和共享的堆,协程由程序员在代码中控制调度,协程切换需要资源少,效率高;

多进程、多线程根据CPU核数的不同是可能实现并行的,协程是在单个线程中,所以是并发的;


End!