作为一名算法…哦不对,是调参工程师,在日常调参之外,偶尔也需要把模型整成一个http服务对外支持,而在这里面最烦的就是业务方要求要大量的并发请求,以及实时性。这时候就会涉及到一些多进程,多线程,协程,异步非阻塞等等概念。这里记录一下自己对这些概念的理解,以及如何将自己的程序耗时缩减到最小。

概念篇

1.线程、进程

进程:对于操作系统而言,进程是最小的资源管理单元。假设有两个任务A和B,当A遇到了一个很复杂的IO操作,此时CPU只能等待A读写完后,再去执行B任务。因此这样会对CPU的资源造成极大的浪费,而一种高效的执行方式是,让A在进行IO操作时,将CPU切换给B执行,当A读取完数据后,再将CPU切换回给任务A。而这就涉及到任务的切换,任务状态的恢复,以及任务所需的系统资源等等。此时就需要一个去记录这些东西,因此进程就被设计出来,计算机通过进程去识别任务,分配调度任务系统资源。而进程的状态的记录、恢复、切换被称之为上下文切换

线程:对于操作系统而言,线程是最小的执行单元。假设我们有一个文本程序,需要接受键盘输入,将输入内容显示到屏幕,还要把内容保存到硬盘中,如果我们只有一个进程,那么我们在同一个时间就只能做一件事情,比如当在接收键盘输入时,我们就不能将内容保存到硬盘中。如果我们采用多进程来做,比如创建三个进程来分别负责这三个任务,这个时候就会涉及到文本内容的共享问题,而我们太频繁的去切换进程就会造成性能上的损失。因此线程就被设计出来,在python中线程是共享内存的,也就是线程能访问到程序中的所有变量。这是线程的最大优势,也是线程中最大的劣势。为什么这么说呢?后面谈到全局解释器锁GIL)再进一步解释。

2.协程

协程:协程又名微线程,是一种用户态内的上下文切换技术。简而言之,就是通过一个线程实现代码块相互切换执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio

async def fun1():
print(1)
# 执行某些网络IO操作,如下载文件等等
await asyncio.sleep(1)
print(2)

async def fun2():
print(3)
# 执行某些网络IO操作,如下载文件等等
await asyncio.sleep(2)
print(4)


task = [asyncio.ensure_future(fun1),
asyncio.ensure_future(fun2)]
loop = asyncio.get_event_loop()
loop.run_until_complete(task)

在python中遇到了IO阻塞时,协程会自动帮你进行切换执行函数。在上述的例子中,执行fun1时优先打印1。当遇到IO操作时,协程会切换线程中的执行函数。此时执行fun2,打印3。遇到IO阻塞后,切换回fun1,打印2,最后切换回fun2,打印4。

其实协程就像一个人做饭,比如在开始蒸米饭后,你不会傻傻的等着米饭熟了再去做菜,而是在米饭开始蒸之后,去洗菜、备菜。协程也是一样,遇到IO阻塞后,会执行切换,这样就可以有效的利用起IO阻塞的时间。

3.GIL锁

3.1 什么是GIL锁?

在说明为什么要用协程之前,我们需要理解什么GIL锁。大家通常说Python里多线程效率不如单线程,其中很大一部分原因是因为GIL锁的存在。那么这个全局锁到底是一个什么东西呢?

GIL:全局解释锁(Global Interpreter Lock)是计算机程序设计语言解释器,用于同步线程的一种机制,它的存在使得一个进程里在任何时刻都只有一个线程在执行。即使是在多核的处理器中,使用GIL的解释器也只允许同一时间运行一个线程。

3.2 为什么要使用GIL?

GIL的问题其实是由于近些年,计算机处理器由单核发展到多核时带来的一个问题。在单核处理器的调度多个线程任务时,这些任务共享一个全局锁,谁在CPU上执行,谁就占有这把锁,直到这个线程因为IO操作或者Timer Tick到期让出CPU,而没有执行的线程就在等待线程让出全局锁再执行。下图展示了单核处理器的线程调度方式:

img

而在现代的多核处理器上,这种调度方式可以被优化,原来在等待的线程任务,可以调度到其他空闲的处理器上执行。如下图所示:

img

如果线程2想要在CPU2上执行,必须要等待线程1释放了GIL锁。当线程1是因为遇到IO阻塞操作释放的,那么线程2必将拿到GIL锁。但如果线程2是因为Timer Ticks计数让出GIL,此时线程1、2公平竞争,但是在py2中,线程1不会动态调整自身优先级,因此线程1大概率继续拿到GIL。在多个选举周期内,线程2都只能看着线程1拿着GIL在CPU1上运行。

实战篇

1.怎么用协程