异步编程漫游指南

 

异步编程漫游指南

原文链接

摘要

C10K问题 仍是软件开发者致力于解决的一个难题。通常,我们通过thread、 epoll或kqueue处理大量 I/O 操作,以避免软件阻塞在一些耗时的IO操作上。然而,由于数据共享和任务依赖性,开发可读且无错误的并发代码具有挑战性。尽管一些强大的工具,例如 Valgrind ,帮助开发人员检测死锁或其他异步问题,当软件规模变大时,解决这些问题可能会很耗时。因此,许多编程语言(例如 Python、Javascript 或 C++)致力于开发更好的库、框架或语法,以帮助程序员正确管理并发任务。本文主要关注异步编程模式背后的设计理念,而不是关注如何使用现代并行API。

使用线程是开发人员在不阻塞主线程的情况下调度任务的更自然的方式。但是,线程可能会导致性能问题,例如对临界区加锁以执行某些原子操作。尽管在某些情况下使用事件循环可以提高性能,但由于回调问题(例如,回调地狱),编写可读代码具有挑战性。幸运的是,像 Python 这样的编程语言引入了一个概念async/await,以帮助开发人员编写易于理解的高性能代码。下图显示了如何像利用线程一样使用async/await处理套接字连接

../images/event-loop-vs-thread.png

介绍

处理诸如网络连接之类的 I/O 操作是程序中最昂贵的任务之一。以一个简单的 TCP 阻塞回显服务器为例(以下代码段)。如果客户端成功连接到服务器 而没有发送任何请求时,它仍会阻塞其他人连接到服务器。此外,服务器对并发请求的处理效率低下,因为它浪费了大量时间等待来自硬件(如网络接口)的 I/O 响应。 因此,并发套接字编程对于管理大量请求变得不可避免。

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5566))
s.listen(10)

while True:
conn, addr = s.accept()
msg = conn.recv(1024)
conn.send(msg)

防止服务器等待 I/O 操作的一种可能解决方案是将任务分派到其他线程。下面的例子展示了如何创建线程来同时处理连接。但是,创建大量的线程可能会消耗所有计算能力,而没有高吞吐量。更糟糕的是,应用程序可能会浪费时间等待锁来处理临界区中的任务。尽管使用线程可以解决套接字服务器的阻塞问题,但其他因素(例如 CPU 利用率)对于程序员克服 C10k 问题至关重要。因此,在不创建无限线程的情况下,事件循环是另一种管理连接的解决方案。

import threading
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5566))
s.listen(10240)

def handler(conn):
while True:
msg = conn.recv(65535)
conn.send(msg)

while True:
conn, addr = s.accept()
t = threading.Thread(target=handler, args=(conn,))
t.start()

一个简单的事件驱动套接字服务器包括三个主要组件:I/O 多路复用模块 (例如 select), 一个调度器 (事件循环), 和回调函数(事件) (事件)。 例如,以下服务器在循环中利用I/O 多路复用选择器, selectors 不了解selectors,(建议先看python官方文档), 来检查 I/O 操作是否准备就绪。如果数据可用于读/写, 则循环获取 I/O 事件并执行回调函数accept、read、 或write以完成任务。

import socket

from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
from functools import partial

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5566))
s.listen(10240)
s.setblocking(False)

sel = DefaultSelector()

def accept(s, mask):
conn, addr = s.accept() # read socket
conn.setblocking(False)
sel.register(conn, EVENT_READ, read) # when conn read is available, call read function

def read(conn, mask):
msg = conn.recv(65535) # read conn
if not msg:
sel.unregister(conn)
return conn.close()
sel.modify(conn, EVENT_WRITE, partial(write, msg=msg)) # modify = unregister + register, when conn write is available, call write function

def write(conn, mask, msg=None):
if msg:
conn.send(msg)
sel.modify(conn, EVENT_READ, read) # when conn read is available, call read function

sel.register(s, EVENT_READ, accept) # when socket read is available, call accept function
while True:
events = sel.select()
for e, m in events:
cb = e.data
cb(e.fileobj, m)

尽管通过线程管理连接可能效率不高,但利用事件循环来调度任务的程序并不容易阅读。为了提高代码可读性,包括 Python 在内的许多编程语言引入了抽象概念,例如协程、future或 async/await 来处理 I/O 多路复用。为了更好地理解编程术语并正确使用它们,以下部分将讨论这些概念是什么以及它们试图解决什么样的问题。

回调函数

当事件发生时,回调函数用于在运行时控制数据流。然而,保持当前回调函数的状态是具有挑战性的。例如,如果程序员想要通过 TCP 服务器实现握手,他/她可能需要在某个地方存储以前的状态。

# client should send hello to server before sending other msg

import socket

from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
from functools import partial

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5566))
s.listen(10240)
s.setblocking(False)

sel = DefaultSelector()
is_hello = {}

def accept(s, mask):
conn, addr = s.accept()
conn.setblocking(False)
is_hello[conn] = False;
sel.register(conn, EVENT_READ, read)

def read(conn, mask):
msg = conn.recv(65535)
if not msg:
sel.unregister(conn)
return conn.close()

# check whether handshake is successful or not
if is_hello[conn]:
    sel.modify(conn, EVENT_WRITE, partial(write, msg=msg))
    return

# do a handshake
if msg.decode("utf-8").strip() != "hello":
    sel.unregister(conn)
    return conn.close()

is_hello[conn] = True

def write(conn, mask, msg=None):
if msg:
conn.send(msg)
sel.modify(conn, EVENT_READ, read)

sel.register(s, EVENT_READ, accept)
while True:
events = sel.select()
for e, m in events:
cb = e.data
cb(e.fileobj, m)

尽管该变量 is_hello 有助于存储状态以检查握手是否成功,但代码对于程序员来说变得更难理解。其实前面实现的概念很简单。它等于以下代码段(阻塞版本)。

def accept(s): conn, addr = s.accept() success = handshake(conn) if not success: conn.close()

def handshake(conn):
data = conn.recv(65535)
if not data:
return False
if data.decode('utf-8').strip() != "hello":
return False
conn.send(b"hello")
return True

要将类似的结构从阻塞迁移到非阻塞,函数(或任务)需要在等待 I/O 操作时对当前状态进行快照,包括参数、变量和断点。此外,调度程序应该能够在 I/O 操作完成后重新进入该函数并执行剩余的代码。与 C++ 等其他编程语言不同,Python 可以轻松实现上面讨论的概念,因为它的**生成器**可以通过调用内置函数next()来保留所有状态和重新进入。通过使用生成器,可以像前面的代码片段一样处理 I/O 操作,但可以在事件循环内访问一个称为*内联回调*的非阻塞形式。

事件循环

事件循环是一个调度程序,用于管理程序内的任务,而不是依赖于操作系统。下面的代码片段展示了一个简单的事件循环如何异步处理套接字连接。实现原理是将任务追加到一个FIFO作业队列中,并在I/O操作未准备好时注册一个选择器。此外,生成器保留任务的状态,使其能够在 I/O 结果可用时无需回调函数即可执行剩余作业。因此,通过观察事件循环的工作原理,将有助于理解 Python 生成器确实是一种形式的 协程

# loop.py

class Loop(object):
def init(self):
self.sel = DefaultSelector()
self.queue = []

def create_task(self, task):
    self.queue.append(task)

def polling(self):
    # select(0), wait until some registered file objects become ready,
    # 0 means the call won’t block, and will report the currently ready file objects
    for e, m in self.sel.select(0):
        # e.data=callback, here is generator main or generator handler
        self.queue.append((e.data, None))  # e.data等待的event已就绪,将e.data加到队列
        self.sel.unregister(e.fileobj)  # 从selector中移除

def is_registered(self, fileobj):
    try:
        self.sel.get_key(fileobj)
    except KeyError:
        return False
    return True

def register(self, t, data):
    # data = (event_mask, fileobj), when event is ready on fileobj, call task t.
    if not data:
        return False

    if data[0] == EVENT_READ:
        if self.is_registered(data[1]):
            self.sel.modify(data[1], EVENT_READ, t)
        else:
            self.sel.register(data[1], EVENT_READ, t)
    elif data[0] == EVENT_WRITE:
        if self.is_registered(data[1]):
            self.sel.modify(data[1], EVENT_WRITE, t)
        else:
            self.sel.register(data[1], EVENT_WRITE, t)
    else:
        return False

    return True

def accept(self, s):
    conn, addr = None, None
    while True:
        try:
            conn, addr = s.accept()
        except BlockingIOError:
            yield (EVENT_READ, s)  # not ready, yield, 交出控制权
        else:
            break  # ready, 返回conn, addr
    return conn, addr

def recv(self, conn, size):
    msg = None
    while True:
        try:
            msg = conn.recv(1024)
        except BlockingIOError:
            yield (EVENT_READ, conn)  # not ready, yield, 交出控制权
        else:
            break
    return msg

def send(self, conn, msg):
    size = 0
    while True:
        try:
            size = conn.send(msg)
        except BlockingIOError:
            yield (EVENT_WRITE, conn)
        else:
            break
    return size

def once(self):
    self.polling()
    while len(self.queue) > 0:
        t, data = self.queue.pop(0)
        try:
            data = t.send(data)  # 返回的data = (event_mask, fileobj)
        except StopIteration:
            continue
        self.register(t, data)  # task t 未就绪,将t在等待的(event_mask, fileobj)加到selector

def run(self):
    while self.queue or self.sel.get_map():
        self.once()

通过将任务分配到事件循环来处理连接,编程模式类似于使用线程来管理 I/O 操作,但使用用户级调度程序。此外, PEP 380 启用了生成器委托,这允许生成器可以等待其他生成器完成他们的工作。显然,下面的代码片段比使用回调函数来处理 I/O 操作更加直观和可读(P.S. 但上面那个loop可不好读)。

# foo.py # $ python3 foo.py & # $ nc localhost 5566

import socket

from selectors import EVENT_READ, EVENT_WRITE

import loop.py

from loop import Loop

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5566))
s.listen(10240)
s.setblocking(False)

def handler(conn):
while True:
msg = yield from loop.recv(conn, 1024) # 委托生成器, 右边迭代完时,msg 为 recv 的return
if not msg:
conn.close()
break
yield from loop.send(conn, msg) # 委托生成器

def main():
while True:
conn, addr = yield from loop.accept(s) # 委托生成器, 右边迭代完时,conn, addr 为 accept 的return
conn.setblocking(False)
loop.create_task((handler(conn), None))

loop.create_task((main(), None))
loop.run()

结论

由于现代语法和库的支持,如今通过事件循环进行的异步编程变得更加简单易读。大多数编程语言,包括 Python,通过与新语法交互来实现管理任务调度的库。虽然新语法一开始看起来很神秘,但它们为程序员提供了一种在其代码中开发像线程一样的逻辑结构的方法。此外,由于在任务完成后不调用回调函数,程序员无需担心如何将当前任务状态(例如局部变量和参数)传递到其他回调中。因此,程序员将能够专注于开发他们的程序,而不会浪费时间来解决并发问题。

posted @ 2021-09-17 00:27  HeapOverflow  阅读(68)  评论(0编辑  收藏  举报