异步回调地狱的解决,为什么需要协程——以js为例讲述发展历程(Promise、async/await、生成器)
本文所讲的协程仅是说明为什么回调地狱通过协程解决和专门讲协程那篇文章结合起来看。
本文所讲的协程仅是说明为什么回调地狱通过协程解决
和专门讲协程那篇文章结合起来看
单线程可以做异步么?
看另一篇文章
为什么需要异步回调
最简单的两个场景,莫过于定时器和网络请求ajax了
首先是最简单的
setTimeout(() => {
console.log('2秒后');
// 这里可以继续执行其他的同步或异步操作
}, 2000);
然后是最古老的XMLHttpRequest,里面有onload方法,onready方法等
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
// 请求成功,处理数据
console.log(xhr.responseText);
} else if (xhr.status !== 200) {
// 请求失败
console.error('Request failed');
}
};
xhr.send();
回调地狱
回调地狱产生的根本原因是异步操作的嵌套和回调函数的链式调用。在异步编程中,常常需要等待一个操作完成后再执行下一个操作,这就导致了回调函数的嵌套。当嵌套层次过多时,代码就会变得难以理解和维护
以下是一个典型的回调地狱示例:
getDataFromServer1(function(data1) {
getDataFromServer2(data1, function(data2) {
processData(data2, function(processedData) {
saveToDatabase(processedData, function(success) {
if (success) {
console.log('All done!');
} else {
console.error('Error saving data to database');
}
});
});
});
});
在这个例子中,四个连续的异步操作形成了三层嵌套,每一层都在等待上一层完成之后才执行
我们分析下本质,是因为因为你写异步回调的时候不能像正常代码中的逻辑那样,一行是一行的。而是要不断地包裹,而且每层都要带上上一层的参数,以及新的逻辑必须在子函数里面写
于是后面出现了解决方案
使用Promise
Promise提供了更简洁的异步处理方式,可以避免回调地狱。例如:
通过新建一个 Promise 对象好像并没有看出它怎样 “更加优雅地书写复杂的异步任务”。我们之前遇到的异步任务都是一次异步,如果需要多次调用异步函数呢?例如,如果我想分三次输出字符串,第一次间隔 1 秒,第二次间隔 4 秒,第三次间隔 3 秒:
实例
setTimeout(function () {
console.log("First");
setTimeout(function () {
console.log("Second");
setTimeout(function () {
console.log("Third");
}, 3000);
}, 4000);
}, 1000);
这段程序实现了这个功能,但是它是用 “函数瀑布” 来实现的。可想而知,在一个复杂的程序当中,用 “函数瀑布” 实现的程序无论是维护还是异常处理都是一件特别繁琐的事情,而且会让缩进格式变得非常冗赘。
现在我们用 Promise 来实现同样的功能:
new Promise(function (resolve, reject) {
setTimeout(function () {
console.log("First");
resolve();
}, 1000);
}).then(function () {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log("Second");
resolve();
}, 4000);
});
}).then(function () {
setTimeout(function () {
console.log("Third");
}, 3000);
});
getDataFromServer1()
.then(data1 => getDataFromServer2(data1))
.then(data2 => processData(data2))
.then(processedData => saveToDatabase(processedData))
.then(() => console.log('All done!'))
.catch(error => console.error('Error saving data to database'));
事实上好像promise好像还可以和fetch、await结合 此处就不过多展开了
可以看到,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。
Promise 的最大问题是代码冗余,原来的任务被Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。
使用async/await:
async/await是建立在Promises之上的语法糖,使得异步代码的书写和阅读更加直观。使用async关键字可以将一个函数声明为异步函数,而await关键字则用于等待一个Promise解决。
async/await提供了更直观的异步编程方式,可以将异步操作写成同步代码的形式。例如:
async function process() {
try {
const data1 = await getDataFromServer1();
const data2 = await getDataFromServer2(data1);
const processedData = await processData(data2);
await saveToDatabase(processedData);
console.log('All done!');
} catch (error) {
console.error('Error saving data to database');
}
}
process();
协程
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
第一步,协程A开始执行。
第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
第三步,(一段时间后)协程B交还执行权。
第四步,协程A恢复执行。
上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。
举例来说,读取文件的协程写法如下。
function asnycJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}
上面代码的函数 asyncJob 是一个协程,它的奥妙就在其中的 yield 命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。
协程遇到 yield 命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。
js实现协程有两种方法
方法1 async/await实现协程
async function asyncFunction() {
console.log('开始');
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('经过2秒');
}
asyncFunction(); // 使用asyncFunction函数自动处理异步操作和等待。
方法2 使用生成器实现协程
Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
function* gen(x){
var y = yield x + 2;
return y;
}
上面代码就是一个 Generator 函数。它不同于普通函数,是可以暂停执行的,所以函数名之前要加星号,以示区别。
整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。Generator 函数的执行方法如下。
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器 )g 。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 g 的 next 方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的 yield 语句,上例是执行到 x + 2 为止。
换言之,next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息( value 属性和 done 属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。
推荐用方法1
在现代JavaScript开发中,推荐使用async/await来实现协程,因为它提供了更清晰、更简洁的语法来处理异步操作。而生成器虽然在某些情况下仍然有用(例如,当你需要精细控制协程的执行流程时),但在大多数情况下,async/await已经足够强大且易于使用。
python的例子展示为什么要协程
例子一
https://blog.csdn.net/weixin_43997319/article/details/124843974
类似epoll方式 通过异步回调实现服务器
下面我们用 Python 编写一段代码,实际体验一下这种编程模式,看看它复杂在哪里。
from urllib.parse import urlparse
import socket
from io import BytesIO
# selectors 里面提供了多种"多路复用器"
# 除了 select、poll、epoll 之外
# 还有 kqueue,这个是针对 BSD 平台的
try:
from selectors import (
SelectSelector,
PollSelector,
EpollSelector,
KqueueSelector
)
except ImportError:
pass
# 由于种类比较多,所以提供了DefaultSelector
# 会根据当前的系统种类,自动选择一个合适的多路复用器
from selectors import (
DefaultSelector,
EVENT_READ, # 读事件
EVENT_WRITE, # 写事件
)
class RequestHandler:
"""
向指定的 url 发请求
获取返回的内容
"""
selector = DefaultSelector()
tasks = {"unfinished": 0}
def __init__(self, url):
"""
:param url: http://localhost:9999/v1/index
"""
self.tasks["unfinished"] += 1
url = urlparse(url)
# 根据 url 解析出 域名、端口、查询路径
self.netloc = url.netloc # 域名:端口
self.path = url.path or "/" # 查询路径
# 创建 socket
self.client = socket.socket()
# 设置成非阻塞
self.client.setblocking(False)
# 用于接收数据的缓存
self.buffer = BytesIO()
def get_result(self):
"""
发送请求,进行下载
:return:
"""
# 连接到指定的服务器
# 如果没有 : 说明只有域名没有端口
# 那么默认访问 80 端口
if ":" not in self.netloc:
host, port = self.netloc, 80
else:
host, port = self.netloc.split(":")
# 由于 socket 非阻塞,所以连接可能尚未建立好
try:
self.client.connect((host, int(port)))
except BlockingIOError:
pass
# 我们上面是建立连接,连接建立好就该发请求了
# 但是连接什么时候建立好我们并不知道,只能交给操作系统
# 所以我们需要通过 register 给 socket 注册一个回调函数
# 参数一:socket 的文件描述符
# 参数二:事件
# 参数三:当事件发生时执行的回调函数
self.selector.register(self.client.fileno(),
EVENT_WRITE,
self.send)
# 表示当 self.client 这个 socket 满足可写时
# 就去执行 self.send
# 翻译过来就是连接建立好了,就去发请求
# 可以看到,一个阻塞调用,我们必须拆成两个函数去写
def send(self, key):
"""
连接建立好之后,执行的回调函数
回调需要接收一个参数,这是一个 namedtuple
内部有如下字段:'fileobj', 'fd', 'events', 'data'
key.fd 就是 socket 的文件描述符
key.data 就是给 socket 绑定的回调
:param key:
:return:
"""
payload = (f"GET {self.path} HTTP/1.1\r\n"
f"Host: {self.netloc}\r\n"
"Connection: close\r\n\r\n")
# 执行此函数,说明事件已经触发
# 我们要将绑定的回调函数取消
self.selector.unregister(key.fd)
# 发送请求
self.client.send(payload.encode("utf-8"))
# 请求发送之后就要接收了,但是啥时候能接收呢?
# 还是要交给操作系统,所以仍然需要注册回调
self.selector.register(self.client.fileno(),
EVENT_READ,
self.recv)
# 表示当 self.client 这个 socket 满足可读时
# 就去执行 self.recv
# 翻译过来就是数据返回了,就去接收数据
def recv(self, key):
"""
数据返回时执行的回调函数
:param key:
:return:
"""
# 接收数据,但是只收了 1024 个字节
# 如果实际返回的数据超过了 1024 个字节怎么办?
data = self.client.recv(1024)
# 很简单,只要数据没收完,那么数据到来时就会可读
# 那么会再次调用此函数,直到数据接收完为止
# 注意:此时是非阻塞的,数据有多少就收多少
# 没有接收的数据,会等到下一次再接收
# 所以这里不能写 while True
if data:
# 如果有数据,那么写入到 buffer 中
self.buffer.write(data)
else:
# 否则说明数据读完了,那么将注册的回调取消
self.selector.unregister(key.fd)
# 此时就拿到了所有的数据
all_data = self.buffer.getvalue()
# 按照 \r\n\r\n 进行分隔得到列表
# 第一个元素是响应头,第二个元素是响应体
result = all_data.split(b"\r\n\r\n")[1]
print(f"result: {result.decode('utf-8')}")
self.client.close()
self.tasks["unfinished"] -= 1
@classmethod
def run_until_complete(cls):
# 基于 IO 多路复用创建事件循环
# 驱动内核不断轮询 socket,检测事件是否发生
# 当事件发生时,调用相应的回调函数
while cls.tasks["unfinished"]:
# 轮询,返回事件已经就绪的 socket
ready = cls.selector.select()
# 这个 key 就是回调里面的 key
for key, mask in ready:
# 拿到回调函数并调用,这一步需要我们手动完成
callback = key.data
callback(key)
# 因此当事件发生时,调用绑定的回调,就是这么实现的
# 整个过程就是给 socket 绑定一个事件 + 回调
# 事件循环不停地轮询检测,一旦事件发生就会告知我们
# 但是调用回调不是内核自动完成的,而是由我们手动完成的
# "非阻塞 + 回调 + 基于 IO 多路复用的事件循环"
# 所有框架基本都是这个套路
import time
start = time.perf_counter()
for _ in range(10):
# 这里面只是注册了回调,但还没有真正执行
RequestHandler(url="https://localhost:9999/index").get_result()
# 创建事件循环,驱动执行
RequestHandler.run_until_complete()
end = time.perf_counter()
print(f"总耗时: {end - start}")
协程方式
import time
from urllib.parse import urlparse
import asyncio
async def download(url):
url = urlparse(url)
# 域名:端口
netloc = url.netloc
if ":" not in netloc:
host, port = netloc, 80
else:
host, port = netloc.split(":")
path = url.path or "/"
# 创建连接
reader, writer = await asyncio.open_connection(host, port)
# 发送数据
payload = (f"GET {path} HTTP/1.1\r\n"
f"Host: {netloc}\r\n"
"Connection: close\r\n\r\n")
writer.write(payload.encode("utf-8"))
await writer.drain()
# 接收数据
result = (await reader.read()).split(b"\r\n\r\n")[1]
writer.close()
print(f"result: {result.decode('utf-8')}")
# 以上就是发送请求相关的逻辑
# 我们看到代码是自上而下的,没有涉及到任何的回调
# 完全就像写同步代码一样
async def main():
# 发送 10 个请求
await asyncio.gather(
*[download("http://localhost:9999/index")
for _ in range(10)]
)
start = time.perf_counter()
# 同样需要创建基于 IO 多路复用的事件循环
# 协程会被丢进事件循环中,依靠事件循环驱动执行
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
end = time.perf_counter()
print(f"总耗时: {end - start}")
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/weixin_43997319/article/details/124843974
例子二
假设这么一个场景,程序需要访问两个网址(通过url)
import tornado
2 from tornado.httpclient import AsyncHTTPClient
3 import time, sys
4
5 def http_callback_way(url1, url2):
6 http_client = AsyncHTTPClient()
7 begin = time.time()
8 count = [0]
9 def handle_result(response, url):
10 print(‘%s : handle_result with url %s’ % (time.time(), url))
11 count[0] += 1
12 if count[0] == 2:
13 print ‘http_callback_way cost’, time.time() - begin
14 sys.exit(0)
15
16 http_client.fetch(url1,lambda res, u = url1:handle_result(res, u))
17 print(‘%s here between to request’ % time.time())
18 http_client.fetch(url2,lambda res, u = url2:handle_result(res, u))
19
20 url_list = [ ‘http://xlambda.com/gevent-tutorial/’,‘https://www.bing.com’]
21 if name == ‘main’:
22 http_callback_way(*url_list)
23 tornado.ioloop.IOLoop.instance().start()
复制代码
运行结果:
1487292402.45 here between to request
1487292403.09 : handle_result with url http://xlambda.com/gevent-tutorial/
1487292403.21 : handle_result with url https://www.bing.com
http_callback_way cost 0.759999990463
从代码可以看到,对请求的结果是放在一个额外的函数(handle_result)中进行的,这个处理函数在发出请求的时候注册的,这就是回调。从运行结果可以看到,在发出第一个请求之后就立即返回了(先于请求结果),而且运行时间大为缩短,这就是异步的优势所在:不用在IO上等待,在单核CPU上就有更好的性能。但是callback这种形式,导致代码逻辑因为异步请求分离,不符合人类的思维习惯,因为不直观。再来看一个很常见的例子:请求一个网址A,根据网页的内容来确定是接着访问B还是C,如果使用异步,那代码是这样的:
复制代码
1 import tornado
2 from tornado.httpclient import AsyncHTTPClient
3 http_client = AsyncHTTPClient()
4 def handle_request_final(response):
5 print(‘finally we got the result, do sth here’)
6
7 def handle_request_first(response, another1, another2):
8 if response.error or ‘some word’ in response.body:
9 target = another1
10 else:
11 target = another2
12 http_client.fetch(target, handle_request_final)
13
14 def http_callback_way(url, another1, another2):
15 http_client.fetch(url, lambda res, u1 = another1, u2 = another2:handle_request_first(res, u1, u2))
16
17 url_list = [ ‘https://www.baidu.com’, ‘https://www.google.com’,‘https://www.bing.com’]
18 if name == ‘main’:
19 http_callback_way(*url_list)
20 tornado.ioloop.IOLoop.instance().start()
复制代码
代码表达的逻辑是连贯的,但代码的变现形式却是割裂的,在这里分散到了三个函数里面。对于程序员来说,这样的代码难以阅读,不容易看一眼就大概明白其意图,而且这样的代码是难以维护的,更糟糕的是,编程实现中还得保存或者传递上下文(比如函数中的参数 another1, another2)。
在python中,由于lambda的功能还是比较弱,所以回调一般都是另外命令一个函数。而在javascript,特别是以异步IO为基石的NodeJS中,由于匿名函数的强大,很轻易嵌套实现callback,这也就出现了让编码者沉默、维护者流泪的callback hell。当然promise对callback hell有一定的改善,但还有没有更好的办法呢?
异步协程方式:
回到顶部
这个时候协程就出马了,本来是异步调用,但是程序上看上去变成了“同步”。关于协程,python中可以用原声的generator,也可以使用更严格的greenlet。首先来看看tornado封装的协程:
复制代码
1 import tornado, sys, time
2 from tornado.httpclient import AsyncHTTPClient
3 from tornado import gen
4
5 def http_generator_way(url1, url2):
6 begin = time.time()
7 count = [0]
8 @gen.coroutine
9 def do_fetch(url):
10 http_client = AsyncHTTPClient()
11 response = yield http_client.fetch(url, raise_error = False)
12 print url, response.error
13 count[0] += 1
14 if count[0] == 2:
15 print ‘http_generator_way cost’, time.time() - begin
16 sys.exit(0)
17
18 do_fetch(url1)
19 do_fetch(url2)
20
21 url_list = [ ‘http://xlambda.com/gevent-tutorial/’,‘https://www.bing.com’]
22 if name == ‘main’:
23 http_generator_way(*url_list)
24 tornado.ioloop.IOLoop.instance().start()
复制代码
运行结果:
http://xlambda.com/gevent-tutorial/ None
https://www.bing.com None
http_generator_way cost 1.05999994278
从运行结果可以看到,效率还是优于同步的,而代码看起来是顺序执行的,但事实上有某种意义上的并行。代码中使用了decorator 和 yield关键字,在看看使用gevent的代码:
复制代码
1 def http_coroutine_way(url1, url2):
2 import gevent, time
3 from gevent import monkey
4 monkey.patch_all()
5 begin = time.time()
6
7 def looks_like_block(url):
8 import urllib2 as urllib
9 data = urllib.urlopen(url).read()
10 print url, len(data)
11
12 gevent.joinall([gevent.spawn(looks_like_block, url1), gevent.spawn(looks_like_block, url2)])
13 print(‘http_coroutine_way cost’, time.time() - begin)
14
15 url_list = [ ‘http://xlambda.com/gevent-tutorial/’,‘https://www.bing.com’]
16 if name == ‘main’:
17 http_coroutine_way(*url_list)
复制代码
代码中没有特殊的关键字,使用的API也是跟同步方式一样的,这就是gevent的牛逼之处,通过monkey_patch就是原来的同步API变成异步,gevent的介绍可以参见《gevent调度流程解析》。
协程与回调对比,优势一目了然:代码更清晰直观,更加复合思维习惯。但协程也不是银弹,首先,协程是个新概念,需要花时间去理解;其次,程序员心里也得牢牢记住,在看似在一起的两句代码中间可能插入了很大其它逻辑(即使是在单线程)。但总体来说,协程给出了人们解决问题的新思路,利大于弊,在其他语言(C# Lua golang)中也有支持,还是值得程序员去了解和学习
更多推荐
所有评论(0)