python多任务:进程线程协程

Mac OS X,UNIX,Linux,Windows等,都是多任务操作系统即操作系统可以同时运行多个任务。对于操作系统来说,一个任务就是一个进程(Process),一个任务可分为多个子任务。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,这些“子任务”称为子进程。一个进程至少有一个线程。进程是系统资源分配的基本单位,线程操作系统调度的基本单元。

操作系统的设计,可以归结为三点:

  • 以多进程形式,允许多个任务同时运行;
  • 以多线程形式,允许将单个任务分成多个子任务运行;
  • 提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。

多任务

  • 并发:指的是任务数多余cpu核数,通过操作系统的各种任务调度算法,实现用多个任务“一起”执行(实际上总有一些任务不在执行,因为切换任务的速度相当快,看上去一起执行而已)

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

  • 单核cpu实现多任务,时间片轮转方式。并发,假的多进程。

  • 多核cpu实现多任务:并行,真的多进程。

1. 多线程

python可使用多线程实现多任务。python提供了thread模块和threading模块,thread是低级模块,后者对其进行了封装。一般我们使用threading模块实现多线程。一个进程至少启动一个线程,该线程称为主线程(MainThread)。主线程可以启动新的线程即子线程。

实现多线程一般有两种方式:

1.1 threading.Thread类创建线程

Thread类语法结构如下:Thread([group [, target [, name [, args [, kwargs]]]]])

  • target:如果传递了函数的引用,子进程就执行函数内的代码
  • name:给线程设定一个名字,可以不设定
  • args:给target指定的函数传递的参数,以元组的方式传递
  • kwargs:给target指定的函数传递命名参数
  • group:指定线程组,大多数情况下用不到

Thread类创建的实例对象的常用方法:

  • start():创建并启动子线程
  • join([timeout]):是否等待子线程执行结束或等待多少秒
  • is_alive():判断进程子进程是否还在活着
from threading import Thread
import time

def info(num):
    for x in range(3):
        print("--------线程%d执行-------" % num)
        time.sleep(1)
    
if __name__ == "__main__":
    t1 = Thread(target=info, args=(1,))
    t2 = Thread(target=info, args=(2,))
    t1.start() 
    t2.start()

# 执行结果
--------线程1执行-------
--------线程2执行-------
--------线程1执行-------
--------线程2执行-------
--------线程1执行-------
--------线程2执行-------
from threading import Thread
import time

def info(num):
    for x in range(3):
        print("--------线程%d执行-------" % num)
        time.sleep(1)
    
if __name__ == "__main__":
    t1 = Thread(target=info, args=(1,))
    t2 = Thread(target=info, args=(2,))
    t1.start() 
    t1.join()  # join()方法阻塞其他线程,直到t1线程执行完才会执行其他进程  
    # time.sleep(5)  (也可使用时间延时控制进程执行顺序)
    t2.start()

# 执行结果
--------线程1执行-------
--------线程1执行-------
--------线程1执行-------
--------线程2执行-------
--------线程2执行-------
--------线程2执行-------
1.2 自定义线程类

自定义线程类应继承于Threading类。线程实例调用start()方法创建并启动线程时,会自动调用run()方法。因此编写线程类时,需要将执行的功能代码封装到run()内部。

# 自定义线程类
import time
from threading import Thread

class Mythread(Thread):
    
    def __init__(self, num):
        self.num = num
        return super().__init__()
    
    def run(self):
        for x in range(3):
            print("-----线程%d执行-----" % self.num)
            time.sleep(0.5)
            
t1 = Mythread(1)
t2 = Mythread(2)
t1.start()  # 自动调用run()方法执行功能代码
t2.start()

# 执行结果
-----线程1执行-----
-----线程2执行-----
-----线程2执行-----
-----线程1执行-----
-----线程2执行-----
-----线程1执行-----
1.3 线程同步(互斥锁)

同一个进程内的线程是共享全局变量的,所以变量可以被任何一个线程修改。

# 线程间共享全局变量
from threading import Thread

num = 100
num_list = []

def update_data():
    global num
    for x in range(5):
        num += 1
        num_list.append(x)
        
def get_data():
    global num
    print("num:%d" % num)
    print("num_list:%s" % num_list)
    
t1 = Thread(target=update_data)
t2 = Thread(target=get_data)

t1.start()
t1.join() # 等待线程t1完成全局变量修改任务
t2.start()  # t2线程执行打印全局变量值看是否被修改

# 执行结果
num:105
num_list:[0, 1, 2, 3, 4]

如果多个线程同时对同一个全局变量操作,会出现资源竞争问题,从而数据结果会不正确。

# 多个线程对一个全局变量修改
import time
from threading import Thread

num = 0

def addition():
    global num
    for x in range(1000000):
        num += 1
    print("")
        
def subtraction():
    global num
    for x in range(1000000):
        num -= 1

t1 = Thread(target=addition)
t2 = Thread(target=subtraction)

t1.start()
t2.start() 
time.sleep(1)

print("num:%d" % num)

# 执行结果:正常情况下应该为0 
num:-119796 

当多个线程几乎同时修改某一个共享数据的时候,需要进行同步控制。线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。

互斥锁为资源引入一个状态:锁定/非锁定。某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

Threading模块提供Lock类来定义一个锁对象。锁对象有两个方法,acquire方法表示获得锁,release方法表示释放锁。

上锁解锁过程:

  • 当一个线程调用锁的acquire()方法获得锁时,锁就进入“locked”状态。
  • 每次只有一个线程可以获得锁。如果此时另一个线程试图获得这个锁,该线程就会变为“blocked”状态,称为“阻塞”,直到拥有锁的线程调用锁的release()方法释放锁之后,锁进入“unlocked”状态。
  • 线程调度程序从处于同步阻塞状态的线程中选择一个来获得锁,并使得该线程进入运行(running)状态。
# 线程同步
import time
from threading import Thread, Lock

num = 0
lock = Lock()

def test1():
    global num
    for x in range(1000000):
        lock.acquire()
        num += 1
        lock.release()
        
def test2():
    global num
    for x in range(1000000):
        lock.acquire()
        num -= 1
        lock.release()

t1 = Thread(target=test1)
t2 = Thread(target=test2)

t1.start()
t2.start() 
time.sleep(1)

print("num:%d" % num)

# 执行结果 
num:0 

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处是阻止了多线程并发执行即包含锁的某段代码实际上只能以单线程模式执行,效率下降,尽量保证加锁的代码尽可能少。而且由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁。

1.4 死锁

多个线程共享多个资源时,若两个线程分别占有一部分资源并且同时等待对方释放资源,会造成死锁。

# 死锁
import threading
import time

class MyThread1(threading.Thread):
    def run(self):
        # 对mutexA上锁
        mutexA.acquire()

        # mutexA上锁后,延时1秒,等待另外那个线程 把mutexB上锁
        print(self.name+'----do1---up----')
        time.sleep(1)

        # 此时会堵塞,因为这个mutexB已经被另外的线程抢先上锁了
        mutexB.acquire()
        print(self.name+'----do1---down----')
        mutexB.release()

        # 对mutexA解锁
        mutexA.release()

class MyThread2(threading.Thread):
    def run(self):
        # 对mutexB上锁
        mutexB.acquire()

        # mutexB上锁后,延时1秒,等待另外那个线程 把mutexA上锁
        print(self.name+'----do2---up----')
        time.sleep(1)

        # 此时会堵塞,因为这个mutexA已经被另外的线程抢先上锁了
        mutexA.acquire()
        print(self.name+'----do2---down----')
        mutexA.release()

        # 对mutexB解锁
        mutexB.release()

mutexA = threading.Lock()
mutexB = threading.Lock()

if __name__ == '__main__':
    t1 = MyThread1()
    t2 = MyThread2()
    t1.start()
    t2.start()

2. 进程

一个程序运行起来后,代码及其利用到的资源称为进程,进程是系统资源分配的基本单位。子进程会复制父进程的代码、数据等资源,所以进程之间不共享全局变量(缺点就是多进程占用系统资源较多)。常用在CPU密集型任务上,可充分利用Cpu的多个核心。使用子进程替代线程可以有效避免全局解释器锁带来的性能影响。

2.1 fork()函数

在Linux和Unix系统中,fork()函数用来创建进程。普通函数,调用一次返回一次。fork()函数调用一次,返回两次。fork()函数创建新的进程(子进程)。子进程是当前进程(父进程)的拷贝,会复制父进程的代码段、堆栈段和数据段。

对于父进程,返回子进程的进程号PID,对于子进程返回0。可根据返回值判断当前进程是子进程或父进程。python的os模块提供了普遍的操作系统功能。

  • os.getpid()用来获取当前进程ID,os.getppid()获取当前进程的父进程ID。
# fork函数
import os

pid = os.fork()

if pid < 0:
    print("process create failed.")
elif pid == 0:
    print("当前进程为子进程")
    print("父进程:%s   子进程:%s" % (os.getppid(), os.getpid()))
else:
    print("当前进程为父进程")
    print("父进程:%s   子进程:%s" % (os.getpid(), pid))

# 执行结果
当前进程为父进程
父进程:891   子进程:1990
当前进程为子进程
父进程:891   子进程:1990
2.2 多进程实现

python提供了multiprocessing模块实现多进程。multiprocessing模块提供了一个Process类来创建一个进程对象。

Process语法结构如下:Process([group [, target [, name [, args [, kwargs]]]]])

  • target:如果传递了函数的引用,可以任务这个子进程就执行这里的代码
  • args:给target指定的函数传递的参数,以元组的方式传递
  • kwargs:给target指定的函数传递命名参数
  • name:给进程设定一个名字,可以不设定
  • group:指定进程组,大多数情况下用不到

Process创建的实例对象的常用方法:

  • start():启动子进程实例(创建子进程)
  • is_alive():判断进程子进程是否还在活着
  • join([timeout]):是否等待子进程执行结束,或等待多少秒
  • terminate():不管任务是否完成,立即终止子进程

Process创建的实例对象的常用属性:

  • name:当前进程的别名,默认为Process-N,N为从1开始递增的整数
  • pid:当前进程的pid(进程号)
# 多进程
from multiprocessing import Process

def test1():
    for x in range(3):
        print("-----------test1-----------")
        
def test2():
    for x in range(3):
        print("***********test2***********")
        
if __name__ == "__main__":
    p1 = Process(target=test1)
    p2 = Process(target=test2)
    p1.start()
    p2.start()
    
# 执行结果
-----------test1-----------
-----------test1-----------
***********test2***********
-----------test1-----------
***********test2***********
***********test2***********

start()方法用于创建并启动进程,join()方法用于阻塞进程。该方法会阻塞调用该方法的进程之外的其他进程,直至该进程执行完毕。其他进程才会继续执行。.

进程之间不共享全局变量

# 进程间不共享全局变量
from multiprocessing import Process

g_num = []

def update_data_01():
    for x in range(5):
        g_num.append(x)
    print("update_data_01函数修改完g_num:%s" % g_num)
        
def update_data_02():
    for x in range(10):
        g_num.append(x)
    print("update_data_02函数修改完g_num:%s" % g_num)
        
p1 = Process(target=update_data_01)
p2 = Process(target=update_data_02)
p1.start()
p2.start()

# 执行结果
update_data_01函数修改完g_num:[0, 1, 2, 3, 4]
update_data_02函数修改完g_num:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
2.3 进程间通信

之前说过进程之间不共享全局变量,因此多进程实现多任务时进程之间需要进行通信即数据传递。进程间通信可通过管道(Pipe)、队列(Queue)等多种方式进行。Pipe常用在两个进程间通信,Queue用来在多个进程间实现通信。

Queue:即队列(FIFO,先进先出)是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作。进行插入操作的端称为队尾,进行删除操作的端称为队头。

初始化Queue()对象时(例如:q=Queue()),若括号中没有指定最大可接收的消息数量,或数量为负值,那么就代表可接受的消息数量没有上限(直到内存的尽头)

  • Queue.qsize():返回当前队列包含的消息数量;

  • Queue.empty():如果队列为空,返回True,反之False ;

  • Queue.full():如果队列满了,返回True,反之False;

  • Queue.get([block[, timeout]]):获取队列中的一条消息,然后将其从列队中移除,block默认值为True

    • 如果block使用默认值,且没有设置timeout(单位秒),消息列队如果为空,此时程序将被阻塞(停在读取状态),直到从消息列队读到消息为止,如果设置了timeout,则会等待timeout秒,若还没读取到任何消息,则抛出"Queue.Empty"异常;
    • 如果block值为False,消息列队如果为空,则会立刻抛出"Queue.Empty"异常;
  • Queue.get_nowait():相当Queue.get(False);

  • Queue.put(item,[block[, timeout]]):将item消息写入队列,block默认值为True;

    • 如果block使用默认值,且没有设置timeout(单位秒),消息列队如果已经没有空间可写入,此时程序将被阻塞(停在写入状态),直到从消息列队腾出空间为止,如果设置了timeout,则会等待timeout秒,若还没空间,则抛出"Queue.Full"异常;
    • 如果block值为False,消息列队如果没有空间可写入,则会立刻抛出"Queue.Full"异常;
  • Queue.put_nowait(item):相当Queue.put(item, False);

下面示例:两个进程向队列中写入数据,一个进程从队列中取出数据。

# 进程间通信:队列
from multiprocessing import Process, Queue
import time

def put_data(sequence):
    infos = ["fengdi", "yuxi", "python", "java", "hello world"]
    for info in infos:
        print("队列中添加数据:%s" % info)
        sequence.put(info)
        time.sleep(0.5)
        if queue.full():
            break

def get_data(sequence):
    while True:
        info = sequence.get()
        print("队列中取出数据:%s" % info)
        time.sleep(0.5)
        if queue.empty():
            break

if __name__ == "__main__":
    queue = Queue(5)  # 定义一个长度为5的队列

    p1 = Process(target=put_data, args=(queue,))
    p2 = Process(target=get_data, args=(queue,))

    p1.start()
    p2.start()
    
# 执行结果
队列中添加数据:fengdi
队列中取出数据:fengdi
队列中添加数据:yuxi
队列中取出数据:yuxi
队列中添加数据:python
队列中取出数据:python
队列中添加数据:java
队列中取出数据:java
队列中添加数据:hello world
队列中取出数据:hello world

Pipe:常用来在两个进程间通信,两个进程分别位于管道两端。Pipe()方法返回元组(conn1, conn2),代表一个管道的两端。该方法有duplex参数,默认为True,即全双工模式<即两端均可收发。若为False,则conn1接受消息,conn2发送消息。send和recv方法分别发送和接受消息。

# 进程间通信:管道
import os, time, random
from multiprocessing import Process, Pipe

def proc_send(pipe, urls):
    for url in urls:
        print('Process(%s) send: %s' % (os.getpid(), url))
        pipe.send(url)
        time.sleep(random.random())

def proc_recv(pipe):
    while True:
        print('Process(%s) recv:%s' % (os.getpid(), pipe.recv()))
        time.sleep(random.random())

if __name__ == "__main__":
    pipe = Pipe()
    p1 = Process(target=proc_send, args=(pipe[0], ['url_' + str(i) for i in range(5)]))
    p2 = Process(target=proc_recv, args=(pipe[1], ))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    
# 执行结果
Process(20908) send: url_0
Process(20909) recv:url_0
Process(20908) send: url_1
Process(20909) recv:url_1
Process(20908) send: url_2
Process(20909) recv:url_2
Process(20908) send: url_3
Process(20909) recv:url_3
Process(20908) send: url_4
Process(20909) recv:url_4
2.4 进程池

python提供进程池方式,可批量创建多个进程,进程池适用于多个任务的情况。初始化Pool时,可以指定一个最大进程数,当有新的请求提交到Pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到指定的最大值,那么该请求就会等待,直到池中有进程结束,才会用之前的进程来执行新的任务。

multiprocessing.Pool常用函数解析:

  • apply_async(func[, args[, kwds]]) :使用非阻塞方式调用func(并行执行,堵塞方式必须等待上一个进程退出才能执行下一个进程),args为传递给func的参数列表,kwds为传递给func的关键字参数列表;
  • close():关闭Pool,使其不再接受新的任务;
  • terminate():不管任务是否完成,立即终止;
  • join():主进程阻塞,等待子进程的退出, 必须在close或terminate之后使用;

在下面示例中,创建了一个有5个进程的进程池,设置了10个要执行的任务。故只有一个任务执行结束,进程池空出位置后,另一个任务才能执行。

# 进程池
from multiprocessing import Pool

def test(num):
    print("------任务%d正在执行-------" % num)
    
# 创建进程池

pool = Pool(5)

for x in range(1, 11):
    pool.apply_async(test, args=(x, ))
    
print("***********主进程%s开始执行***************" % os.getpid())

pool.close()
pool.join()

print("***********主进程%s结束执行***************" % os.getpid())

# 执行结果
***********主进程20982开始执行***************
------任务1正在执行-------
------任务2正在执行-------
------任务3正在执行-------
------任务6正在执行-------
------任务7正在执行-------
------任务4正在执行-------
------任务8正在执行-------
------任务9正在执行-------
------任务5正在执行-------
------任务10正在执行-------
***********主进程20982结束执行***************
2.5 进程池间通信

进程间通信使用队列即processing.Queue类创建队列对象,进程池进程间队列通信使用processing.Manager()中的Queue类类创建队列对象。

import time
from multiprocessing import Manager, Pool

def put_data(queue):
    info_list = ["python", "java", "php"]
    for info in info_list:
        print("向队列中插入数据:%s" % info)
        queue.put(info)
        time.sleep(0.5)

def get_data(queue):
    while True:
        if queue.empty():
            break
        info = queue.get()
        print("从队列中取出数据:%s" % info)
        time.sleep(0.5)

pool = Pool()
queue = Manager().Queue()
pool.apply_async(put_data, args=(queue,))
pool.apply_async(get_data, args=(queue,))

pool.close()
pool.join()

# 执行结果
向队列中插入数据:python
从队列中取出数据:python
向队列中插入数据:java
从队列中取出数据:java
向队列中插入数据:php
从队列中取出数据:php
2.6 进程线程区别
  • 定义

    • 进程是系统进行资源分配和调度的一个独立单位.
    • 线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源.
  • 区别

    • 一个程序至少有一个进程,一个进程至少有一个线程.
    • 线程的划分尺度小于进程(资源比进程少),使得多线程程序的并发性高。
    • 进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率

3. 协程

协程是python个中另外一种实现多任务的方式,只不过比线程更小占用更小执行单元(理解为需要的资源)。

通俗的理解:在一个线程中的某个函数,可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的,并且切换的次数以及什么时候再切换到原来的函数都由开发者自己确定。

协程和线程差异:在实现多任务时, 线程切换从系统层面远不止保存和恢复 CPU上下文这么简单。 操作系统为了程序运行的高效性每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作, 所以线程的切换非常耗性能。但是协程的切换只是单纯的操作CPU的上下文,所以一秒钟切换个上百万次系统都抗的住。协程一般是在一个线程内通过切换调用函数实现。

3.1 greenlet实现协程
# 协程:greenlet
import time
from greenlet import greenlet

def test1():
    for x in range(5):
        print("---------test1---------")
        t2.switch()
        
def test2():
    for x in range(5):
        print("*********test2**********")
        t1.switch()
        
t1 = greenlet(test1)
t2 = greenlet(test2)

t1.switch()

# 执行结果
---------test1---------
*********test2**********
---------test1---------
*********test2**********
---------test1---------
*********test2**********
---------test1---------
*********test2**********
---------test1---------
*********test2**********
3.2 gevent实现协程
# 协程:gevent
import gevent

def f(n):
    for i in range(n):
        print(gevent.getcurrent(), i)
        gevent.sleep(0.5)


g1 = gevent.spawn(f, 3)
g2 = gevent.spawn(f, 3)
g3 = gevent.spawn(f, 3)

g1.join()
g2.join()
g3.join()

# 执行结果
<Greenlet at 0x10f5129d8: f(3)> 0
<Greenlet at 0x10f512488: f(3)> 0
<Greenlet at 0x10f5126a8: f(3)> 0
<Greenlet at 0x10f5129d8: f(3)> 1
<Greenlet at 0x10f512488: f(3)> 1
<Greenlet at 0x10f5126a8: f(3)> 1
<Greenlet at 0x10f5129d8: f(3)> 2
<Greenlet at 0x10f512488: f(3)> 2
<Greenlet at 0x10f5126a8: f(3)> 2

4. 进程线程协程总结

  • 进程是资源分配的单位
  • 线程是操作系统调度的单位
  • 进程切换需要的资源很最大,效率很低
  • 线程切换需要的资源一般,效率一般(当然了在不考虑GIL的情况下)
  • 协程切换任务资源很小,效率高
  • 多进程、多线程根据cpu核数不一样可能是并行的,但是协程是在一个线程中所以是并发

5. GIL(全局解释器锁)

GIL(全局解释器锁)是python的历史遗留问题,仅在cpython解释器里面存在。所以在执行多线程操作时,多个线程只能交替执行即同一时刻在只有有个线程在执行,不能调用多个CPU内核,只能利用一个内核。因此执行CPU密集型任务时,多使用多进程实现,可利用多个CPU内核;执行IO密集型任务时,多使用多线程实现,可明显提高效率(多线程遇到IO阻塞会自动释放GIL锁)。下面我们编写一个示例:线程数和CPU核心数(2核)相同。验证多线程执行时,利用几个CPU内核?

# GIL
from threading import Thread

def test():
    while True:
        pass
    
t1 = Thread(test)
t1.start()

while True:
    pass

在上面代码中两个个线程执行的函数均为死循环,两个进程执行时,最终的CPU占用率在50%左右。也就是说虽然同时运行了2个进程,但实际上只使用了1个CPU内核。这正是GIL的原因。

要想解决多线程执行中的GIL问题可以:

  • 更换解释器,GIL仅存在于cpython解释器中
  • 使用其他语言编写线程任务代码

推荐阅读更多精彩内容