python线程、进程、协程详解

python线程、进程、协程

python的GIL

GIL简介

python的GIL的全程是global interpreterer lock(全局解释器锁)

在cpython中,python的一个线程对应c语言的一个线程,早期一些历史原因,GIL使得在一个进程中的一个时间点上只有一个线程在执行python编译的字节码。这就意味着一个线程中无法让多个线程映射到多个cpu上,不能在一个线程内实现并行。

GIL的释放

示例:

import threading

a = 0


def add():
    global a
    for i in range(1000000):
        a += 1


def sub():
    global a
    for i in range(1000000):
        a -= 1


t1 = threading.Thread(target=add)
t2 = threading.Thread(target=sub)

t1.start()
t2.start()

t1.join()
t2.join()

print(a)

上面这段代码开启两个线程,一个对a进行加1的操作,执行1000000次,另一个对a进行减1的操作,执行1000000次,然后等两个线程执行完毕,打印a的值。

按照正常思维,打印的结果应该是0,但我们每次运行发现,结果都是不一样的,有些远远大于0,有些远远小于0。

上面说到,python因为GIL的存在,在一个进程中的某一时刻只有一个线程在运行python编译的字节码。比如拿上面的例子来说,可能t1线程计算到a=99,已经确定了下次计算的结果为100,但还未来的及给a赋值为100的时候,python切换到了t2线程。假设t2线程执行了10次,计算了a的结果为89,同上,已经确定了下次计算的值为90,但还未来的及给a赋值为90的之后,线程切换到了t1,继续上次线程未完成的操作,将a赋值给了100。这样就造成了t2线程执行的那10次的for循环"失效"了。如此往复,只要for循环的次数足够大,"失效"的次数就会多起来,最后得到的值也就变差很大了。

从上面的例子可以看出,python的GIL会在适当的时候切换线程,它并不会等到一个线程完全执行完毕才会释放。实际上GIL会根据线程执行的任务的字节码的长度和时间片来随机释放,或者GIL在遇到IO阻塞的时候也会释放。

多线程

操作系统能够调度的最小单元是线程,无论是什么并发模型,底层"干活"的一定是线程

如果没有共享资源,不同的线程之间是不会相互影响的,线程必须依赖进程存在,在进程结束时,子线程全部退出。一个进程中一定有一个主线程。

线程实例

import time


def test():
    print(666)
    time.sleep(3)
    print(777)


if __name__ == '__main__':
    import threading

    t1 = threading.Thread(target=test)
    t1.start()

    print("end")

运行上面的代码:

666
end
777

上面的例子中主线程中开启了子线程,子线程执行test函数,最后主线程在即将退出的时候打印出"end"。从最后的结果可以看出,当主线程结束的时候,子线程并不会受到影响,在3秒后打印出"777",子线程结束完毕之后,整个程序结束。

守护线程

有时候我们希望当主线程结束的时候,子线程全部都要kill掉,这时候就要使用Thread提供的一个方法setDaemon 。

import time


def test():
    print(666)
    time.sleep(3)
    print(777)


if __name__ == '__main__':
    import threading

    t1 = threading.Thread(target=test)
    t1.setDaemon(True)
    t1.start()

    print("end")

还是上面的例子,只是在t1线程开始之前调用它的一个setDaemon方法。这时候当前的t1线程就被成为守护线程。当主线程结束时,t1也随之结束。

运行结果:

666
end

阻塞等待

既然有上面的守护线程,有时候就想让所有的子线程全部执行完毕,再去执行主线程中操作

import time


def test():
    print(666)
    time.sleep(3)
    print(777)


if __name__ == '__main__':
    import threading

    t1 = threading.Thread(target=test)
    t1.start()
    t1.join()
    print("end")

在子线程开始后,执行当前线程实例的join方法,主线程就会等待子线程的执行完毕

执行结果:

666
777
end

线程通信

线程间通信有多种方式,共享内存、网络、文件、数据库...

这里简单介绍下共享内存

共享内存就是多个线程来对一块内存就行操作,但是这样会有线程安全的隐患(参考上面GIL随机释放的例子),要想线程安全,就得对共享的内存进行加锁。并不是很推荐使用锁的方式,在共享内存中,锁的释放和开启都是有程序来主动控制,把锁的逻辑和业务的逻辑放在一起会造成可读性降低。

使用队列来通信

import threading


def pop(quene):
    while True:
        data = quene.get()
        print(data)


def insert(quene):
    for i in range(20):
        quene.put(i)


if __name__ == '__main__':
    from queue import Queue

    quene = Queue(maxsize=20)

    t1 = threading.Thread(target=insert, args=(quene,))
    t2 = threading.Thread(target=pop, args=(quene,))
    t1.start()
    t2.start()

python为我们提供了一个Quene的类, Quene实例化的时候会接受一个maxsize的参数来确定内部的队列的最大程度。

Quene的实例是一个引用类型,put方法将一个对象传入的队列中,get方法从队列中取出一个对象。同时,put和get都是阻塞的,当队列中没有数据的时候,get阻塞,当队列满的时候,put阻塞。

Quene是线程安全的,它的内部使用了deque, deque是一个线程安全的双向队列。

使用Quene能够在上层减少对锁的操作,简化代码,提升代码的可读性。

Quene中还提供了很多功能,如join:等待完成,task_down:任务完成,put_nowait:非阻塞插入队列,get_nowait:非阻塞从队列中获取....

线程同步

线程在原则上是不相互影响的,但在最开始的GIL释放的例子中,由于两个线程共享了全局的变量,GIL随机切换造成了线程在执行字节码的时候并不是理想的状态。

这时候就需要两个线程间的某些代码片段同步执行,这时候就需要线程同步。

import threading

a = 0

lock = threading.Lock()


def add():
    global a
    global lock
    for i in range(1000000):
        lock.acquire()
        a += 1
        lock.release()


def sub():
    global a
    global lock
    for i in range(1000000):
        lock.acquire()
        a -= 1
        lock.release()


t1 = threading.Thread(target=add)
t2 = threading.Thread(target=sub)

t1.start()
t2.start()

t1.join()
t2.join()

print(a)

这是最开始的那段代码,上面我们分析到造成最终结果不为0的主要原因是在两个线程最后给a赋值的时候GIL切换了线程。

threading库提供了锁的机制,这里我们将锁加在给a赋值的地方,这样即使当GIL切换了线程,当需要给a赋值的时候必须要拿到锁,拿到锁的线程的锁的资源没有被释放,别的线程就无法拿到锁,代码就无法继续向下执行。这样就可以在线程中实现局部同步的效果。

运行代码,最终的结果为0

Note

锁的使用会影响当前程序的性能

锁运用的不好会造成死锁,导致程序无法正常运行

可重用锁

threading提供了可重用锁,它可以在一个线程中当锁的资源没释放的情况下,多次去获取锁并不会阻塞,但获取锁的次数必须和释放锁的次数一致。

import threading

a = 0

lock = threading.RLock()


def add():
    global a
    global lock
    for i in range(1000000):
        lock.acquire()

        # 使用Lock这里会阻塞,Rlock并不会
        lock.acquire()
        lock.release()
        a += 1
        lock.release()


def sub():
    global a
    global lock
    for i in range(1000000):
        lock.acquire()
        a -= 1
        lock.release()


t1 = threading.Thread(target=add)
t2 = threading.Thread(target=sub)

t1.start()
t2.start()

t1.join()
t2.join()

print(a)

Condition

import threading

lock = threading.Lock()


def func1():
    global lock

    lock.acquire()
    print(1)
    lock.release()

    lock.acquire()
    print(3)
    lock.release()


def func2():
    global lock

    lock.acquire()
    print(2)
    lock.release()

    lock.acquire()
    print(4)
    lock.release()


t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)

t1.start()
t2.start()

上面的这段代码我们希望打印的顺序为1234,但运行的结果不为我们期望的那样。这时候使用Lock来实现线程同步就不可以了

import threading

lock = threading.Condition()


def func1():
    global lock

    lock.acquire()

    print(1)
    lock.notify()

    lock.wait()
    print(3)

    lock.notify()

    lock.release()


def func2():
    global lock

    lock.acquire()
    lock.wait()
    print(2)
    lock.notify()

    lock.wait()
    print(4)
    lock.release()


t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)

t1.start()
t2.start()

将上面的Lock对象改为Condition。

Condition实例使用之前的结束的时候要分别调用acquire和release

Condition实例的wait方法会等待Condition实例的notify方法的调用,notify方法会通知wait不再阻塞,上面的例子反复调用wait和notify来达到线程同步的效果。

但是代码一运行,程序只打印出1后就阻塞住了。

Condition运行的顺序

上面的代码中,t1线程线运行,t2后运行,在t1开始时,运行notify,这时候t2的wait还没执行到,但t1已经运行了notify,t2在阻塞的时候没有notify来通知它,所以它会一致阻塞。

所以,在使用Condition时,应该让阻塞的线程先运行。

控制线程的运行量

一次开启运行多个线程,但线程量太大对操作系统是一种负担,有时候我们只希望在开始的线程中运行固定数量的线程来减少cpu的压力。这时候就可以用到Semaphore

import threading
import time

lock = threading.Semaphore(4)


def func1(l):
    time.sleep(3)
    print("success")
    l.release()


for i in range(20):
    lock.acquire()
    t = threading.Thread(target=func1, args=(lock,))
    t.start()

Semaphore对象接受一个最大线程数的参数,当当前进程内的活跃线程达到最大线程数时,剩下的线程就会被sleep,当活跃线程结束时,需要调用release来通知Semaphore的实例内部的计数器减1。

运行这段代码:每个3秒就会在屏幕上打印4个success,一个会打印5次。

线程池

为什么需要线程池

线程频繁的创建和销毁会消耗cpu的资源,为了让线程充分的利用,可以常见线程池来进行任务调度。

线程池可以控制并发的数量,当线程池满时,剩下的任务就得等待线程池中有空闲的线程才可以进行调度,这样减少操作系统的压力。

简单的的实例

from concurrent.futures import ThreadPoolExecutor
import time

executor = ThreadPoolExecutor(max_workers=2)


def test():
    time.sleep(3)
    print("test done")
    return "test"


def done_callback(response):
    print(response)


task = executor.submit(test)

task.add_done_callback(done_callback)

print(task.done())  # False
print(task.running())  # True
print(task.result())  # test
print(task.done())  # True
print(task.cancel())  # False

首先实例化一个线程池对象,线程池对象可以传入一个池的容量,如果不传,那么会默认为当前机器的cpu的数量。

通过线程池对象的submit方法可以将任务传入线程池,如果线程池没满,那么会去执行这个任务,如果池满了就会去等待线程池空闲。

submit方法是非阻塞的,它会返回一个Future的对象,可以通过Future对象的一些方法来获取当前任务的一些属性和状态。

done: 非阻塞方法, 判断当前Future对象关联的任务是否已经完成

running: 非阻塞方法,判断当前的Future对象关联的任务是否正在运行。

cancel: 非阻塞方法,取消当前Future对象关联的任务。如果当前Future对象关联的任务是在“排队”,那么cancel返回True,并取消任务。如果当前Future对象关联的任务正在执行,那么将无法取消,返回False。

result:阻塞方法,该方法会等待Future对象关联的任务执行结束,并返回任务的执行结果。

add_done_callback:为当前的Future对象的处理结果增加回调函数。

获取完成的任务

上面介绍可以通过线程池的submit方法返回的Future对象来获取任务的返回结果,假设任务数目过多,我们一个个来获取,显然显得不方便。

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

executor = ThreadPoolExecutor(max_workers=2)


def test(s_time: int):
    time.sleep(s_time)
    return f"test sleep {s_time}s"


tasks = (executor.submit(test, i) for i in range(4))

for future in as_completed(tasks):
    data = future.result()
    print(data)

同as_completed方法来获取线程池中的任务的返回结果。

这种方法谁先完成就先返回谁

from concurrent.futures import ThreadPoolExecutor
import time

executor = ThreadPoolExecutor(max_workers=2)


def test(s_time: int):
    time.sleep(s_time)
    return f"test sleep {s_time}s"


for data in executor.map(test, range(4)):
    print(data)

通过executor.map来获取返回结果

这种方式会按照传入线程池的顺序来返回。

主线程阻塞

from concurrent.futures import ThreadPoolExecutor, as_completed, wait, ALL_COMPLETED
import time

executor = ThreadPoolExecutor(max_workers=2)


def test(s_time: int):
    time.sleep(s_time)
    return f"test sleep {s_time}s"


tasks = [executor.submit(test, i) for i in range(4)]

wait(tasks, return_when=ALL_COMPLETED)

for future in as_completed(tasks):
    data = future.result()
    print(data)

可以使用wait方法来使得程序的主线程阻塞。

wait可以设置超时时间、指定等待某个task的完成后解除阻塞状态。

wait的使用非常灵活,更多的可以去查看它的源码及其注释。

多进程

进程是操作系统资源分配的单元。一个进程的所有线程可以空享其进程的资源,但不同进程的资源是相互隔离的。

由于python中存在GIL锁,所以多线程无法发挥多cpu的优势,但是多进程可以,每个进程中python虚拟机的资源是独立的。

但是进程占用的资源比线程多,操作系统切换进程比切换线程要慢,所以多进程不一定比多线程快。

多进程的简单实用

from multiprocessing import Process
import time


def test(n):
    time.sleep(n)
    print("test over")


if __name__ == '__main__':
    process = Process(target=test, args=(2,))
    process.start()
    print("main over")

打印结果:

main over
test over

上面代码可以看到,进程的使用和线程使用的接口几乎是一致的,主线程结束后,子线程并不会随之结束。

与线程不同的是,线程可以在启动后主动退出,调用线程实例的kill方法即可。

但在windows下,进程以及后面的进程池的使用必须放在if name == 'main'才可以。

进程的使用很简单,更多的可以去查看multiprocessing的api

进程池

简单使用

from multiprocessing import Pool
import time


def test(n):
    time.sleep(n)
    return f"test slept {n}s"


if __name__ == '__main__':
    pool = Pool()

    task = pool.apply_async(test, args=(2,))
    pool.close()

    pool.join()
    print(task.get())  # get会阻塞到结果返回
    print("main over")

代码很简单,看完上面的,这里的很容看懂,就不过多赘述了。

其中注意的就是在调用join之前,一定要将进程池关闭。

获取返回结果

from multiprocessing import Pool
import time


def test(n):
    time.sleep(n)
    return f"test slept {n}s"


if __name__ == '__main__':
    pool = Pool()

    for result in pool.map(test, range(3)): 
        print(result)

    for result in pool.imap(test, range(3)):
        print(result)

    for result in pool.imap_unordered(test, range(3)):
        print(result)

上面介绍了3中方式来获取请求结果。他们差别在于对请求结果的排序返回,感兴趣的可以深入研究一下。

使用ProcessPoolExecutor来创建线程池

from concurrent.futures import ProcessPoolExecutor
import time


def test(n):
    time.sleep(n)
    return f"test slept {n}s"


if __name__ == '__main__':
    executor = ProcessPoolExecutor()

    for data in executor.map(test, range(4)):
        print(data)

ProcessPoolExecutor的接口几乎和ThreadPoolExecutor的一致,具体使用看上面叙述。

进程间通信

由于进程间资源是相互隔离的,所以通过一般的方式来共享全局变量的方式是不可行的

from queue import Queue

这种队列只适合线程间通信,进程间是行不通的。

简单实例

from multiprocessing import Queue, Process
import time


def test1(q):
    q.put("args")


def test2(q):
    time.sleep(3)
    print(q.get())


if __name__ == '__main__':
    queue = Queue()

    t1 = Process(target=test1, args=(queue,))
    t2 = Process(target=test2, args=(queue,))
    t1.start()
    t2.start()

通过multiprocessing包提供的Queue可以完成进程间通信,Queue的api和前面的线程中的Queue使用基本相同。

但是这个方式不能用于进程池间的通信。

进程池通信

from multiprocessing import Manager, Pool
import time


def test1(q):
    q.put("args")


def test2(q):
    time.sleep(3)
    print(q.get())


if __name__ == '__main__':
    queue = Manager().Queue()

    pool = Pool()

    t1 = pool.apply_async(test1, args=(queue,))
    t2 = pool.apply_async(test2, args=(queue,))

    pool.close()
    pool.join()

将queue替换为Manager().Queue()即可。

进程间通信的方式还可以通过管道,共享特殊的全局变量

if __name__ == '__main__':
    from multiprocessing import Manager, Pipe

    share_dict = Manager().dict()
    pipe = Pipe()

有兴趣的可以去了解一下,使用起来也很简单。

并发、并行、同步、异步、阻塞、非阻塞

并发和并行

并发和并行是两个概念,经常会说高并发,但基本没人说高并行。

  • 并发:一段时间内,有几个程序在一个cpu上运行,但在该时间段内的一个时间点上只有一个程序在cpu上运行
  • 并行:在一个时间点上,有几个程序在几个cpu上同时运行。

并行完全依赖物理资源,而并发对物理资源的依赖程序低一些。

同步、异步

  • 同步:同步是指代码调用IO操作时,必须等待IO操作完成才能继续调用的方式。
  • 异步:指代码调用IO操作时,不用等待IO操作完成也能继续调用的方式。

阻塞和非阻塞

  • 阻塞:调用函数的当前的线程被挂起
  • 非阻塞:调用函数的时候,当前线程不会被挂起,而是继续执行。

协程和异步IO

C10K问题

如何在一颗1GHz CPU, 2G内存,1gbps的网络环境中为10000个客户端提供FTP服务。

在这样的环境下,如果开启10000个线程来为客户端服务,显然是很难实现的。

python著名的tornado框架就是为了解决C10K的问题而诞生的。

IO多路复用

SELECT、POLL、EPOLL

SELECT 、POLL、EPOLL都是IO多路复用的机制。IO多路复用就是通过一种机制来监听多个描述符,一个某个描述符准备就绪(可读、可写),就能通知程序进行相应的操作。但是select、poll、epoll本质都是同步IO,因为他们都需要在读写时间就绪后自己负责进行读写,读写的过程是阻塞的,而异步IO则无须自己负责进行读写,异步IO的实现则会负责将数据从内核拷贝到用户空间。

如果有go语言开发经验的话,会容易理解这个select, goselect可以监听多个chan,如果监听的chan中有非阻塞的,那么就去执行对应的case,否则就一定等待。

同样的,这里的select可以监听多个描述符,当监听的对象的状态发生改变的时候,select就立即返回,否则就一直等待。select立即返回之后,需要遍历fdset来获取就绪的描述符。select单个线程能够监听的描述符的数量存在最大限制,在linux下为1024。

select使用三个位图来表示三个fdset, 而poll使用一个pollfd的指针实现。

pollfd没有最大限制,同select一样,poll返回之后,需要轮询pollfd来获取就绪的描述符。随之描述符的数量增长,其效率也会线性降低。

epoll是在linux2.6内核提出的,是select和poll的增强版本。epoll没有描述符的限制。epoll使用一个描述符来管理多个描述符,将用户关系的描述符的事件存放到内核的一个时间表中,这样用户空间和内核空间的copy只需要一次。epoll查询内部使用红黑树的数据结构,查询起来特比快。

但select并不代表比epoll差,分场景。

在并发高,用户活跃度不高(web系统),epoll要优于select

在并发不高,用户活跃度高 ( 游戏服务 ), select要优于epoll

协程

C10M

如何利用8核心,64内存,在10gbps的网络上保持1000万的并发连接

随着互联网技术日新月异的发展,c10k已经不能满足需求,c10m成为了一种挑战。

但,同步变成的并发性不高,多线程编程需要线程同步,加锁会降低性能。线程的创建切换的开销大。

现在希望可以使用写同步代码的方式去编写异步的代码,可以使用单线程去切换任务。

但,线程的切换是有操作系统完成的,单线程的任务切换意味着开发者需要自己去调度任务。

实例:

def get_html(url):
    pass


def parse_html(html):
    pass


def get_next_url_list_from_html(url):
    html = get_html(url)  # io等待
    next_url_list = parse_html(html)


get_next_url_list_from_html("your url")

写过爬虫的对上面的代码一定不陌生。从一个网页中解析出需要爬取的url。

但是从网络中获取html源码是一个IO操作,必须得等待html获取完成才可以继续执行html解析。假设,这个段代码循环10次,每次等待的时间为0.5秒,那么这段代码有5秒的时候cpu是空闲的。这时候我们希望,在执行get_html的时候,当前的函数可以暂停,等待IO操作执行完了再切换回来,中间等待的时候让cpu去执行别的任务。

协程

asyncio

asyncio是python3.4引入的一个异步框架

简单使用

import asyncio


async def test1():
    await asyncio.sleep(3)
    return "test1 done"


if __name__ == '__main__':
    # 事件循环
    loop = asyncio.get_event_loop()

    # 给事件循环添加任务
    task = loop.create_task(test1())

    # 阻塞的方法,直到所有的任务全部完成!
    loop.run_until_complete(task)

    # 获取返回结果
    print(task.result())

task返回的是一个asyncio提供的Future对象,它和线程的Future对象的接口使用起来类似。

批量注册

import asyncio


async def test1():
    await asyncio.sleep(3)
    return "test1 done"


if __name__ == '__main__':
    # 事件循环
    loop = asyncio.get_event_loop()

    # 给事件循环添加任务
    tasks = [test1() for i in range(10)]

    # 阻塞的方法,直到所有的任务全部完成!
    loop.run_until_complete(asyncio.wait(tasks))

使用的是asyncio.sleep的休眠,还段代码是不会阻塞的,这个代码运行完毕使用了3秒多。

import asyncio


async def test1():
    await asyncio.sleep(3)
    return "test1 done"


def callback(response):
    print(response)


if __name__ == '__main__':
    # 事件循环
    loop = asyncio.get_event_loop()

    task = loop.create_task(test1())

    task.add_done_callback(callback)

    loop.run_until_complete(task)

给任务添加回调函数

关于loop还有很多高级的功能,这里只是简单演示一下

import asyncio


async def test1(time):
    await asyncio.sleep(time)
    print("test")


if __name__ == '__main__':
    # 事件循环
    loop = asyncio.get_event_loop()

    tasks = [test1(i) for i in range(4)]

    try:
        loop.run_until_complete(asyncio.wait(tasks))
    except KeyboardInterrupt:
        all_tasks = asyncio.Task.all_tasks(loop)
        for task in all_tasks:
            print(task.cancel())

        # 这里一定要写上,不然会报错
        loop.stop()
        loop.run_forever()
    finally:
        loop.close()

任务取消,在控制台运行代码,然后按ctrl+c停止就可以正常运行

我的运行结果:

test
test
False
False
True
True
True

在协程中集成阻塞IO

有时候在协程里调用别的api,但是该api是阻塞的,这时候就需要在协程里集成阻塞IO了

from concurrent.futures import ThreadPoolExecutor
import asyncio
import time


def get_html(url):
    time.sleep(2)
    return f'get html from {url} success'


if __name__ == '__main__':

    start = time.time()

    loop = asyncio.get_event_loop()
    executor = ThreadPoolExecutor()

    # 单个任务
    tasks = loop.run_in_executor(executor, get_html, 'http://www.baidu.com')

    # 多个任务
    results = []
    for i in range(5):
        task = loop.run_in_executor(executor, get_html, 'http://www.baidu.com')
        results.append(task)
    loop.run_until_complete(asyncio.wait(results))

    print(time.time() - start)  # 2.0136215686798096

asyncio的同步和通信

asyncio这个框架提供了跟线程模块同样的同步机制,使用起来和线程模块的同步机制几乎没有区别。

import asyncio

lock = asyncio.Lock()

results = []


async def put():
    global lock, results
    await lock.acquire()
    results.append(1)
    await asyncio.sleep(3)
    lock.release()


async def get():
    global lock, results
    async with lock:
        item = results.pop()
        print(item)


tasks = [put(), get(), ]

loop = asyncio.get_event_loop()

loop.run_until_complete(asyncio.wait(tasks))
posted @ 2020-07-30 17:32  Ivy丶  阅读(373)  评论(0编辑  收藏  举报