在 I/O 密集型的应用中,CPU 可能总是苦苦等待着 I/O 操作的完成。如果是一个提供 Web 的服务的话,也就意味着一个线程会因为 I/O 阻塞而无法快速的对其他请求进行响应。势必也造成一种资源浪费和效率低下。在这种时候,协程的价值就体现了出来,例如基于 Python 语言实现的 tornado Web 框架就是基于此原理。
从概念上讲,协程,又称微线程、纤程,英文名 Coroutine。它类似于子例程,但执行过程中,在子例程内部可中断,然后转而执行别的子例程,在适当的时候再返回来接着执行。以一个 Web 请求的处理为例,当用户发起请求时,我们需要从后端数据库中提取所需的数据,这个操作可能会很慢。这时,如果是协程,则可以在此先中断,转而让别人先执行,待数据库的查询返回之后再返回来继续完成数据的组装并最终返回给用户。这样就感觉和谐了许多。
如果对协程还是感觉一头雾水,可以参考一些理论性的文章或讨论,只要在搜索引擎上查找一下,会找到很多写得非常好的资源。在这里,我的想法是从应用的角度去真实的体验一下协程。在近一段时间,因为团队的项目对推送的需求大大增加,因此又重回了对这一块的改进。当然我们团队的项目主要针对 iOS,因此这里所说的推送即 APNs。在大概 10 天以前前,我写了一篇《苹果推送 APNs Provider API 在 Python 中的使用》的博客,因此在本次改进中,我换用了最新的基于 http/2 的 API,不得不说它具有很多新的内容、玩法和优势,并且更易用。那现在要思考的就是如何改进推送的并发能力。
在此之前,我是用线程的方式来实现的。但众所周知,在 Python 中 GIL 饱受争议(我对此不予置评,一个东西的存在本来就具有两面性。重在我们得深入原理并理解其用意所在。),它在一定程度上使得多线程的优势无从体现(当然,其实在这个需求场景下,多线程的效率一样会很高,所以获取是个不怎么恰当的理由)。这时候,使用协程就会更合理一些。这里面,gevent 库算是一个代表,它是一个基于 libev 的并发库,为各种并发和网络相关的任务提供了整洁的 API。以下是一段 gevent 与我之前所做的 APNs 推送相结合的测试代码(这不是一份完整的代码,仅展示了几个关键函数):
...
import gevent
from gevent.queue import Queue
...
def _push_new(pid, task_queue):
"""
从队列中获取推送内容并向 APNs 发起推送
:param pid: 线程标识
:param task_queue: 任务队列
:return:
"""
client = APNsClient(cert_file=settings.APNS_CERT_FILE,
use_sandbox=settings.APNS_USE_SANDBOX)
while 1:
print('Push thread {0} is waiting tasks.'.format(pid))
task = task_queue.get()
print('Push thread {0} is working for {1}.'.format(pid, task.get('token_hex')))
# 推送
payload = Payload(alert=task.get('message'),
badge=task.get('badge'),
sound=task.get('sound'),
custom=task.get('custom'))
stream_id = client.send_notification(token_hex=task.get('token_hex'),
notification=payload,
topic=APNS_TOPIC)
# 获取返回
status, reason = client.get_callback(stream_id)
# 接下来对返回进行处理
if not status == 200:
...
def _fetch_new(timeout, task_queue):
"""
从 Redis 队列中获取“一组推送”,对其“解包”处理后放至推送队列
:param timeout: Redis 队列等待超时时间
:param task_queue: 任务队列
:return:
"""
print('Fetching threading is working now.')
while 1:
# 从 Redis 中获取需要推送的用户及内容
push = _fetch_from_queue(timeout)
if push:
check_db_connection()
message, sound, push_record_id = push.get('message'), push.get('sound', 'default'), push.get(
'push_record_id')
mapping_users = push.get('users')
...
# 在此省略针对平台相关的一些处理逻辑
pushing_devices = ...
...
for device in pushing_devices:
message_user = get_message_user(device.user, device.device)
badge = get_unread_message_count(*message_user)
task_queue.put({
'token_hex': device.token,
'message': message,
'badge': badge or 1,
'sound': sound,
'custom': push.get('custom', {})
})
while not task_queue.empty():
gevent.sleep(1)
def start_push2(timeout=10, num_of_threads=5):
"""
推送主入口方法
:param timeout: Redis 队列等待超时时间
:param num_of_threads: 启动的协程数
:return:
"""
from gevent import monkey
monkey.patch_socket()
task_queue = Queue()
gevent.signal(signal.SIGTERM, gevent.killall)
threads = [gevent.spawn(_push_new, i, task_queue) for i in range(num_of_threads)]
threads.append(gevent.spawn(_fetch_new, timeout, task_queue))
gevent.joinall(threads)
_push_new() 方法用于从共享的任务队列中获取推送内容并向 APNs 发起推送。该共享的任务队列是由主入口方法 start_push2() 生成并传入的。该方法会被 spawn 多个作为子例程,以此利用协程达到并发推送的目的,每个子例程中都会新建一个自己的 APNs 连接用于推送和接收反馈。
_fetch_new() 方法用于从 Redis 队列中获取“一组推送”,对其“解包”处理后放至推送队列。Redis 队列中保存的是来自于平台上其他服务的推送需求,队列中的每一个元素都是一组推送,可能针对一个用户,也可能是一组用户。因此 _fetch_new() 的目的就是为了将“组”拆分成独立的“一人份”,然后放至推送队列中。
start_push2() 则是主入口,它首先利用 猴子补丁(monkey patching) 来将 socket 库变为协作式运行:
from gevent import monkey
monkey.patch_socket()
然后初始化任务队列:
task_queue = Queue()
最后再按需启动各个“子程序”,让他们协同完成推送任务。这样,一个针对协程的应用场景就出来了。当然这仅仅是一个简单的测试。
协程有很多内容值得更深入的研究,也包括像进程、线程以及协程等他们之间的关系。以下一些文章我认为值得一读:
gevent 程序员指南:http://xlambda.com/gevent-tutorial/
[译] Python 3.5 协程究竟是个啥:http://blog.rainy.im/2016/03/10/how-the-heck-does-async-await-work-in-python-3-5/