异步是怎么一肥事
我高中开始写程序的时候,学完语言、数据结构、算法以后,最开始搞的是图形库和游戏。一开始接触到的图形库是SDL,当时还是SDL1.2。
这两年接触到了python3,接触到了nodejs,接触到了golang,再想想当时写游戏,协程和事件循环可不就是一回事儿么。
这是一篇针对新手写的协程的解释。熟悉协程的就不必细看了,我写的也不是很好,见笑了。新手读之前建议买杯奶茶,对JavaScript、Python和Go不了解的话可以提前略微了解一下。
事件循环
这世界上所有程序,可以分成两种:会结束的,不会结束的。
会结束的一般就是一些比较小的,输入的数量有限而且易于处理的程序。最简单的例子,print,打印一个值,打印完了就是完了,不会等着你做什么。
不会结束的一般是处理复杂的、重复的、输入数量趋向于无穷多的程序。这类程序,其主体其实都是一个无尽或者有条件无尽的循环,处理一个个的事件,我们都称之为事件循环。
A+B
事件循环最最最简单的例子就是A+B问题。这是ACM-ICPC或者OI最简单的水题。输入若干对整数,输出每对之和,一行一个。最简单的解也就是
#include <cstdio>
int main(void) {
int a, b;
while(scanf("%d %d", &a, &b) != EOF) {
printf("%d\n", a+b);
}
}
我们给它输入,它就会给出输出
$ gcc -o a+b a+b.cpp
$ ./a+b
1 1
2
2 3
5
3 5
8
....
在A+B问题中,每行输入,就是一个事件,事件带两个参数。每次执行循环,我们都处理一个事件,处理的方法就是把两个参数加起来,打到终端上。Easy。
图形交互界面
A+B问题,乃至其他的OJ题目,都还是有穷的,我们可以提前预知会有多少数据,所以我们还是有可能用顺序的方法来处理这些数据。
但是GUI就不一样了。有谁能预知用户的输入吗?有谁知道一个用户会用什么样的顺序敲击键盘?会用什么样的轨迹挪动鼠标?会在什么时候点击什么位置?没有人能预测,能预测的话这个世界已经被独裁了。
那么我们就需要分门别类的去处理用户输入,并且作出响应。我做游戏也是从这里开始的。
以SDL2为例
我用的第一个C/C++图形库是SDL1.2,新的是SDL2.0.x。事件处理上差异不是特别大,我就用现在比较熟悉的SDL2做例子了。
完整的SDL2代码框架大约是这样的:
个别地方我记不清了,代码只是大概意思
#include <SDL.h>
#include <cstdio>
#include <cstdlib>
// ....other functions like loading resources
int main(void) {
if(SDL_Init(SDL_INIT_EVERYTHING) == 0) { // when SDL_Init fails, print and return
fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
return -1;
}
atexit(SDL_Quit);
SDL_Window *wind = SDL_CreateWindow("hello, world!",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
800, 600,
0
);
int quit = 0;
while(!quit) {
SDL_Event event;
while(SDL_PollEvent(&event)) {
switch(event.type) {
case SDL_QUIT:
quit = 1;
break;
// handles other types of event, like keyboard or mouse events.
}
}
// update data
// update ui
// fps cap
}
SDL_DestroyWindow(wind);
}
换句话说,大约是这么个过程
- 初始化
- 循环
- 处理事件
- 更新数据
- 更新UI
- 有必要的时候清理一下
其中的循环就是事件循环,我们在循环中处理数据,在更新UI的时候根据数据画出新的UI,看起来就像是我们直接操作了UI一样。而且实际上所有的图形库都是这个样子的。
服务器
网络上的服务器是一个更极端的例子。图形库还有可能被人关闭,服务器是很少关闭,也很少重启的。
但是和图形库一样,服务器也是处于一个大循环内:
- 初始化
- 循环
- 解析请求
- 更新或拉取数据
- 响应请求
- 有必要的时候清理一下
举个例子,Golang中,启动一个http服务,最简单的方法是http.ListenAndServe
,最后代理到了http.Server.Serve
这个方法,具体的实现我们可以直接由源码得到:
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := srv.setupHTTP2_Serve(); err != nil {
return err
}
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
defer srv.trackListener(&l, false)
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
go c.serve(connCtx)
}
}
加上整个trace stack,简化以后我们得到了两个阶段:初始化和无限循环
func (srv *Server) Serve(l net.Listener) error {
listener, err := 监听()
// 处理err
defer listener.Close()
ctx, err := 建立上下文()
for {
// 接受请求
// 包装请求
// 在协程内处理请求
}
没有退出的代码,退出的唯一可能性是外界给了一个信号使得Golang处理中断并且停下,比如说SIGINT。
单线程事件循环的繁琐
基于事件处理数据在有的情况下很美好,但是也有它不好的一面。
一次一件事情
上面的单线程代码中,除了Go的协程以外,所有的事件循环,每次都只处理一个事件。这就造成了一个问题:资源无法被充分利用。
举个例子,我爹炒菜是没有办法分心做别的事情的,午饭晚饭时段附近,只要电话接不通大概率就是在做饭。这个过程很像单线程的事件循环。
- 初始化:我爹买菜洗菜切菜热锅
- 事件循环
- 处理事件:
- 锅足够热:丢进去葱姜蒜
- 葱姜蒜出香味:丢进去菜
- 菜略微变软:丢进去肉
- 肉熟了一半:放调味料
- 肉熟了:出锅
- 翻炒
- 处理事件:
- 盛菜、吃饭、洗锅
这个过程是十分无聊的,炒了几十年菜,闭着眼睛都把菜抄的香喷喷的。那能不能分个神看看手机呢?不能。
看手机是一个同步阻塞过程,如果不能及时从这个过程中退出来,这锅菜就糊了。严重一点,瓦斯泄漏可能都察觉不到,误把水仙当蒜苗炒了都察觉不到
所以我爹就只能空闲着自己的大脑,让它一次只处理一个请求。更不用说我了,我闲的时候就是翻炒,遇到事件就手忙脚乱的。
不好维护
炒菜是一个比较简单的例子,即使这样我列出来的事件类型也有五种。生活里面我们的食谱菜谱实际上就是事件循环的配置指南,游戏里面的事件循环更是复杂,而且计算量庞大。
那么我们要维护事件循环就显得非常繁琐。用游戏,比如说看门狗2举例:
- 角色 状态变化
- 角色和NPC 视野变化
- 物理引擎
- 天气系统
- ……
这都是大类,如果要靠这样的事件循环维护状态,不止是维护困难,实际上我们机器的性能也不够烧的。
阻塞
上面的这些例子里面,有一个很明显的特点就是事件循环都是阻塞的。那么如果有一个循环出现了bug一直没有完成,这个进程就报废、进入无响应的状态了。
比如说,举个例子,炒菜的时候找食盐,一直找没有找到,那么菜就会糊。如果游戏这样设计,那比如说一次天气的计算无法完成,整个游戏就会卡住。如果服务器这样设计,那么假设我们展示一个页面就要10ms,这一秒就只能处理100个请求。这都是不可以接受的情况。
解决方法
所以我们在复杂的情况下,会用一些其他技巧来解决上面的问题。
线程
最简单粗暴的方法,就是建立一个线程来处理新的请求。但是这不是最好的方法。
简单说思路的话,就是遇到问题就新建一个线程来处理。比如说新建线程来处理一个http请求。
但是这样资源消耗巨大,线程是一个比较庞大的数据结构,根据系统会不大一样,但是当请求量大的时候,线程数上升,资源消耗就会明显起来。同时线程同步的问题也会越来越多,越来越复杂,形成新的问题。
有一些额外的技巧可以缓解问题,比如说线程池,但是还是很麻烦。
异步
异步是一个比较大的进步。使用起来也比较复杂。
简单一些的异步就是回调函数,比如说早期JavaScript中的onclick啊什么的。再有就是JavaScript后来也出了一些比如说Promise的方法,把回调变成函数链。
复杂一些的异步,就是select、poll、epoll、kqueue、还有Windows的异步IO啊什么的。
不管是哪一种,异步一般来说比较复杂一些,需要编码的人无时无刻的去考虑异步和阻塞、资源竞争、错误捕获和处理之类的问题,是略微反人类一些的手段。但是用好了可以在资源消耗不增加太多的情况下有非常多的性能提升,其中典范就是Nginx。
协程
提到线程就说到现今比较火的协程。从JavaScript说起吧。
JavaScript
刚才提到说,JavaScript里面加入了promise,通过把函数串在一起,缓解了callback嵌套太深、难以维护的问题。
代码大致是这样:
fetch("https://www.bilibili.com").then((res) => {
// do A
}).then((res) => {
// do B
}) // ...
fetch("https://www.zhihu.com").then((res) => {
// do A
}).then((res) => {
// do B
}) // ...
那么更符合直觉得写法,可能会和这个更相近一些
doA = () => {
// do A
}
doB = () => {
// do B
}
var result = fetch("/xxxx").result();
result = doA(result);
result = doB(result);
但是问题是,Promise正如名字所说,只是一个promise,你拿到promise的时候只是拿到了一个承诺,我会帮你做xxx,那么有结果了吗?不一定。我们就需要有个方法,可以像同步的过程一样,等待Promise的结果。
于是NodeJS加入了一个语法,就是async/await
let doA = async() => {
// do A
};
let doB = () => {
// do B
}
let doSomething = async (url) => {
var result = await fetch(url);
print(result)
};
doSomething("https://www.bilibili.com")
doSomething("https://www.zhihu.com")
在这段程序里面,我们定义了doA和doB两个函数。
其中,doA用到了一个async语法糖,也就是实际上doA返回了一个Promise,这个promise一开始并没有结果,既没有完成也没有失败,等doA做完函数内的各种事情以后,解释器会自动把函数原本的结果填入promise,并且调用then中的回调,把之前promise的结果传递给函数,然后再次开始等待,直到then中的回调全部依次执行完。
定义了两个函数以后,我们执行了一个匿名的函数。这个函数也是一个async函数。在匿名函数中,我们await了fetch("/xxx")
,await了doA()
。await在这里起的作用就是让程序暂时从这个函数中跳出来,等promise完成后返回这个promise的结果。
Python
无独有偶,特别流行的语言中,最近几年python和C#,甚至C++也有,都也引入了async/await语法。
我就用我熟悉的python做例子。
举个简单的例子,访问百度。我们之前同时访问两个链接,都需要用线程才能做到,同时访问多少个链接,就需要多少个线程。
from threading import Thread
import requests
class ClientThread(Thread):
def __init__(self, url):
self.url = url
self.response = None
def run():
self.response = requests.get(self.url)
def main():
threads = [ClientThread(url) for url in ["https://www.bilibili.com/", "https://www.zhihu.com"]]
for thread in threads: thread.start()
for thread in threads: thread.join()
print([thread.response for thread in threads])
Python从3.4开始,也提供了async/await语法支持,和nodejs类似,也是返回了一个对象,等待函数完成后,结果会填回对象内。在python中,这一类对象叫Awaitable,由async函数返回的叫coro,也就是coroutine,普通函数也可以返回future。
比如说asyncio.sleep函数,和time.sleep类似,都是暂停一段时间然后继续执行。不一样的是time.sleep会暂停整个线程,而asyncio.sleep返回一个coroutine,这个coroutine在一段时间后标记为已完成,只有你await的时候才会暂停当前协程,而不会阻塞整个线程。
同样是访问两个url的例子,用协程的话我们只要用一个主线程就够了。
import aiohttp
import asyncio
loop = asyncio.get_event_loop()
async def main():
response = await asyncio.gather([
aiohttp.get("https://www.bilibili.com/"),
aiohttp.get("https://www.zhihu.com/"),
])
print(response)
if __name__ == "__main__":
loop.run_until_complete(main())
想要验证是不是只用一个线程的话,只要检查进程内的线程数就行。可能你家网比较快,代码一闪而过,试试看
import asyncio
loop = asyncio.get_event_loop()
async def do_something():
print("start")
await asyncio.sleep(30)
print("end")
async def main():
response = await asyncio.gather([
do_something(),
do_something(),
])
if __name__ == "__main__":
loop.run_until_complete(main())
看起来两个协程同时打印了开始,休息了30秒,最后打印了结束。而线程数仍然是1。
是不是有点神奇。
loop
上面的例子里面,我没有解释到的就是loop。
我们可以print一下loop,类型应该是叫_UnixSelectorEventLoop或者_WindowsSelectorEventLoop。
这里这个event loop……名字怎么看起来有点像上面SDL里面的事件循环……?
没错,这里的event loop和上面的事件循环就是同一个东西。只不过要处理的事件变成了异步任务的事件,比如说IO可以读写了、时间到了,诸如此类的事件。
event loop负责调度函数执行,遇到IO或者future就暂停当前函数,IO可用或者future完成的时候再从之前暂停的地方继续。
为什么JavaScript没有event loop
因为JavaScript解释器本身就是event loop……
顺带一提在Python里面corotine不会自动加入loop,JavaScript里面会。比如说,Azure Storage Explorer在2019年之前有个bug是有的时候不会自动退出,原因是setInterval()会让event loop一直处理定时器的消息,最后一直挂着不退出。
假如Python里面遇到协程没有await自动就执行了,那一定是生成coroutine和或者Future的函数用了loop.add_task
,或者在别的地方设置了结果。比如说tornado的RequestHandler中,self.finish返回了一个future,这个future在底层connection完成写入时通过回调设置了结果。
async函数的上下文切换
刚才提到事件循环是异步和协程的基础,但是我们还没有搞清楚协程是怎么样和事件模型产生关系的,我们也没有搞清楚为什么异步的框架可以同时执行两个协程。
在我们的眼里,同步的函数就是两个状态:正在执行,结束。我们用同步函数的角度去想这个问题,就是无解的。
在异步的模型里面,一个函数从开始执行到结束有这么三个状态:正在执行,暂停和结束。正在执行和结束都是和同步类似的过程,但是多了一个暂停。这个暂停就是上面问题的核心。
所谓暂停就是遇到了很有可能不能马上完成的任务,比如说sleep,比如说IO。计算任务需要占用cpu,不在考虑范围内。
比如说Python
import asyncio
loop = asyncio.get_event_loop()
async def hey():
print("1")
await asyncio.sleep(3)
print("2")
loop.create_task(hey())
loop.create_task(hey())
loop.run_forever()
执行结果应该是同时打印了1,然后同时打印了2,看起来协程是同时执行的。
1
1
2
2
看代码的话,我们启动了一个事件循环,在事件循环中执行函数hey。那么hey做了什么呢。
- 打印
- sleep
- 打印
这里的sleep是一个asyncio的特殊的函数,它会导致线程休眠进入暂停状态,休眠的同时会保存协程的上下文到事件循环中,比如说执行到了哪里,本地变量都有哪些。直到数秒后计数器给了event loop一锤:醒醒,该去继续执行那个协程了。这个时候event loop就会去恢复上下文。并且继续执行这个协程,打印出了另一段文字。
正是有这段休眠,才让event loop有了空闲去执行别的协程,让各个协程看起来是在同时运行。
如果我们只是用了普通的sleep函数,暂停了整个线程,协程的优势就荡然无存了。比如说
import asyncio
import time
loop = asyncio.get_event_loop()
async def hey():
print("1")
time.sleep(3)
print("2")
loop.create_task(hey())
loop.create_task(hey())
loop.run_forever()
就只是普通的按照顺序执行了两个函数。
1
2
1
2
因为在hey里面没有任何一个函数可以触发协程的暂停状态。
带来的改变
在有非阻塞模型以前
在有异步以前我们的程序都需要借助线程进程才能处理多个事情。
程序的逻辑简单清楚明了,只要顺序的读下去就行。但是要么会浪费计算资源,要么会浪费内存。
在有异步和事件模型之后
我们可以利用事件循环、和回调来编写异步的代码,只不过比较复杂一些。
举个例子,我们用glut写一个游戏。glut是一个很简单的库,原理就是在glutMainLoop
函数中,初始化OpenGL上下文后,进入一个无限循环,根据情况反复调用几个固定的用户定义的回调函数。
如果我要做个flappy bird,这当然就足够了。毕竟我们只是要管按键事件、小鸟的运动状态和柱子空隙的两端的值,和游戏进行中和结束的两种场景。
但是如果这个场景复杂以后,就很难搞了,我们就需要在这些函数里面,维护非常多的状态,会需要根据情景在event loop中注册或者去掉各种事件的回调函数。如果处理不好就可能写一堆bug。
在有协程和async/await语法糖之后
简单说就是我们不再需要自己去处理事件,不需要担心资源消耗。只要用同步的思路,写需要做什么,代码会自己去管理事件,去调度协程和事件循环的关系。我们就可以在代码清晰易懂的前提下,编写出来一个高性能的程序。
顺便一提这两天尝试用dart和flutter写app,dart真的很像javascript
Golang的协程
前面说的都是 C 、 JavaScript 和 Python ,一直没有提到 Go 语言。因为 Go 语言的协程实现略微特殊一些。
前面说 JavaScript 和 Python 都是维护了同一个事件循环,是单线程的。当然必要的情况可以开多线程,但是只是说事件循环的话是没有必要的。Go不是。
MPG
Go的模型本身和其他两个略微不大一样。Go使用了一种MPG的模型来调度协程。
M表示一个线程,用来调度和执行协程。P表示Processor,不是处理器的processor,而是一个调度器,本质来说就是一个事件循环。G就是go的协程,goroutine。
线程执行调度器,调度器调度和执行协程,协程就是你写的代码了。什么时候使用多少个线程,怎么调度,都是Go语言运行时的事情,我们主要是能简单配置一些选项,比如说最多使用的线程数。
举个例子
举个例子。
我们写一个最小的Go程序:
package main
import (
"time"
)
func main() {
for idx := 0; idx < 8; idx ++ {
go func() {
for {
time.Sleep(time.Minute)
}
}()
}
fmt.Println(runtime.NumGoroutine())
time.Sleep(time.Minute)
}
我们执行一下这个代码,并且通过pstree看看这个进程都有啥:
$ go build -o test test.go
$ ./test &
9
[1] 2902418
# Linux
$ pstree -p 2902418
test(2902418)─┬─{test}(2902420)
├─{test}(2902421)
├─{test}(2902422)
└─{test}(2902423)
# Mac
$ ps -M 71328
USER PID TT %CPU STAT PRI STIME UTIME COMMAND
guochao 2902418 s000 0.0 S 31T 0:00.00 0:00.00 ./test
2902418 0.0 S 31T 0:00.00 0:00.00
2902418 0.0 S 31T 0:00.00 0:00.00
2902418 0.0 S 31T 0:00.00 0:00.00
2902418 0.0 S 31T 0:00.00 0:00.00
2902418 0.0 S 31T 0:00.00 0:00.00
我们可以看到实际上go启动了几个线程。线程的数量和系统和硬件都有关系。
这几个线程就是我们上面说的M(Machine),每个M都会关联一个P,一个P会带着一个表记录着它负责的G。不同的G在P的调度下,看起来如同是在并行执行一样。
简单说,Go在运行时这一级实现了自动调度的多个事件循环。
特殊情况
一般来说我们写代码其实是有资源的热点的,会在某个事件段内大量用某种资源,在其他时间使用其他资源或者空闲下来。
比如说我们可能会在某个核心上大量执行编码解码或者加密解密,就会持续占用一个M和一个P。
Go在这方面也做了非常多的事情,比如说实现了work stealing的机制,来在P之间转移G。
这也让用户在编码时,需要考虑的问题更少,会出的问题更少。
除了web以外的应用
由于Go真的很擅长处理并发的程序,很多人也用Go搞些奇奇怪怪的事情,比如说写游戏。
相对于其他语言的优势
Go是我最近相对来说特别喜欢的语言之一。
生态上Go和其他的语言相比还不是特别完善,尤其是像Java这样的什么工具都有。
但是从异步和协程来说,Go可以用同步的语言,同步的方法,编写非常高效的异步代码,我只要一个go关键字就可以创建协程,不需要自己管理事件循环。
而Python之类的语言,多多少少需要人去修改代码,重构模块,才能使用异步的代码。用线程、线程池的方法也可以在事件循环内正常使用同步的代码,但是占用的资源还是很多,实际上是协程和线程的混合模型。需要尽可能利用多个核心的情况,我们也需要想办法自己处理多个队列,或者启动多个服务。
在这方面,我还是蛮喜欢Go的。谁不想要一只Gopher呢?