Tags   Server TCP asyncio

Back

网络通信技术实践: (5)异步TCP通信

1 基本TCP通信的问题

让我们回顾一下简单TCP服务端的代码:

from network import *

server=tcpserver(8080)

while True:
    clnt=server.accept()
    if clnt==None: continue
    data,addr=clnt.recv()
    print("Received: "+data.decode())
    clnt.send(data)
    clnt.close()

在上述代码中,accept、recv和send等操作都是阻塞式的,这意味着服务器一次只能连接一个客户端,与这个客户端完成一系列通信后,才能连接下一个客户端。假设与单个客户端的通信时间跨度很长,而多数时间是在等待信息到来,那么这个通信的效率显然是非常低下的。

即使设置成非阻塞形式,仍会在上述操作失败之后引发异常,以反复尝试直到成功的形式完成上述操作。在这种情况下,性能开销全部损耗与异常处理,也不是我们想要的。

能否让服务端在等待某个服务器通信的间隔去处理别的客户端?

2 多线程(Multithreading)

让我们考虑这样的情景:现有A、B两个任务需要执行,我们首先完成A,然后再完成B。这是顺序执行的。在这种情况下,执行A任务的时候,B任务是无法进行的。

现在你决定,轮流完成A、B两项任务,即先做1秒A任务,再做1秒B任务,接着又做1秒A任务,然后再做1秒B任务……如此循环,直到某一项任务先完成,然后再集中精力完成剩余一项任务。这种轮流执行A、B两项任务的方法,使得A、B两项任务看起来在同时推进(称为并发)。这正是多线程的形象解释。

显然,在A、B两个任务将切换也是需要时间的。对于A、B两个任务,如果在做B任务的时候A任务恰好在等待其他工作完成(例如等待输入),那么相当于时间得到了充分利用,切换的时间代价可以忽略。当然,也有这样的可能:A任务中正在努力运算,然而轮转的时间到了,这时需要切换到B任务,那么时间并没有被节约,反而额外需要时间完成从A切换到B的工作。如果实际的任务数很多,那么大把的时间会浪费在任务切换上。这也是多线程优点和缺点的展现。

在计算机运行过程中,把一个处理器划分为若干个短的时间片,每个时间片依次轮流地执行处理各个应用程序。由于一个时间片很短,相对于一个应用程序来说,就好像是处理器在为自己单独服务一样,从而达到多个应用程序在同时进行的效果。这就是多线程。


容易注意到,accept、recv和send三种操作中,时间主要用在了等待过程中。因此,使用多线程可以节约时间,并且使得多个连接能够并发。具体来说,我们可以设置主线程,在其中不断进行accept操作接受客户端,一旦accept成功,创建新线程来处理本客户端的recv和send操作。

然而,如果连接的并发数很大(例如成千上万),为每个连接都创建一个线程将会是很大的开销,这相当于把时间浪费在创建和销毁线程上。此外,这种定时切换的方法就很有可能在某线程忙碌的时候打断它,从而把时间浪费在切换上。


线程还存在一些问题:假设有一张表格,每条数据都有5个字段,线程A正在修改某条数据。当线程A刚修改完前2个字段后,就被调度暂停并切换到线程B,而此时线程B刚好又要查询这条数据。此时这条数据只被修改了一半,有可能造成逻辑不自洽,从而带来问题。这被称为线程安全问题,需要通过互斥锁、信号量等手段,让线程B等待线程A完成来解决。

3 线程池(Thread Pool)

通过上述分析,我们发现创建过多的线程显然是不合适的。现在,假设你有一个池塘,里面有许多鱼。你饲养了3只猫,而这3只猫负责将整个池塘的鱼全部吃完。你负责捞鱼并把鱼分配给猫。

我们把鱼看成任务,把猫看作CPU,假设每个猫“同时”只吃2条鱼,就相当于每个CPU分配2个线程,而你就相当于分配任务的监管者。在这种模式下,线程的数量较少,因而线程创建、销毁以及切换的开销也较少。每个线程在轮转过程中可能会执行不同的任务。

线程池特别适用于这种通信的场景:任务数量庞大且不断出现,每个任务大部分时间用于是在等待数据到达。

4 协程(Coroutine)

线程池的效率高于每个连接创建一个线程的方法,但还可以改进:让每个任务在忙碌的过程中不要被打断,只在空闲的时候进行调度。在这种情况下,我们不再进行定时轮转的任务,而是改为闲时切换。在这种情况下,每个任务称为协程

协程不像线程那样定时调度,其可以切换/调度的时机需要程序员手动指定,例如“等待某个任务完成后再继续进行本任务”,因此通过合理指定调度时机避免线程中会遇到的线程安全问题,不再需要互斥锁、信号量等手段来控制运行顺序。

5 Python异步TCP通信

在Python中,可以使用async关键字来指定一个函数异步执行,用await关键字使得异步函数在执行到某一步时停下来并等待另一函数完成(还可以取得其返回值)。此外,通过asyncio库的事件队列机制,我们可以很方便的创建基于协程的异步执行的TCP服务器,如以下代码所示:

import asyncio

async def handle_echo(reader, writer): # 每当socket接受了一个连接,就执行本函数
    data = await reader.read(1024) # 等待socket数据接收完成,在此期间可以让别的协程干活
    message = data.decode()
    addr = writer.get_extra_info('peername')
    print("Received %r from %r" % (message, addr))
    print("Send: %r" % message)
    writer.write(data)
    await writer.drain() # 等待socket数据发送完成,在此期间可以让别的协程干活
    print("Close the client socket")
    writer.close()

loop = asyncio.get_event_loop() # 创建事件循环
coro = asyncio.start_server(handle_echo, '127.0.0.1', 8888, loop=loop) # 监听一次127.0.0.1:8888,接受到连接的时候就异步运行handle_echo函数
server = loop.run_until_complete(coro) # 循环运行上述监听过程

# 运行直到按下 Ctrl+C退出
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

# 关闭服务器
server.close() # 开始关闭服务器,即不再接收新的连接
loop.run_until_complete(server.wait_closed()) # 等待所有已有连接完成
loop.close() # 中止事件循环

#本段代码修改自https://www.lookxue.com/blog/o618899e.html

目录

上一节

下一节