协程: 理解, 实现与一个应用例子

协程(coroutine)是在Python编程中时常被提起的概念, 其的好处为人盛赞, 主要是CPU切换context的开销比进程, 线程都要小. Python中协程的技术原理是yield关键字与generator机制, 其的实现方式是让多个任务函数相互通过yield让出cpu执行权, 再通过send或者是next()+队列+sleep来返回原协程. 和一个实际游戏中多游戏世界同时运行的需求来探讨协程.

1. Python中协程的实现基础: yield与generator

协程的实现基础是python的generator机制和yield关键字.

generator机制: python中的generator机制指的是包含有yield关键字的generator function被执行后可以返回一个generator object的特性.

generator function: 指在函数体中写了yield关键字的一类函数--这类函数没有return, 只有yield. 其一旦被执行后返回的是一个generator object.

generator object: 被generator function返回的这个对象. 它能够传入作为入参被next(gen_obj)方法调用.
generator object本身属于iterator的一种. 而iterator又是对类似list, tuple, dict等实现了iter方法的iterable object进行遍历的一类对象.

关于iterable, iterator, generator相关知识, 可以更细致地参考这里https://anandology.com/python-practice-book/iterators.html

这里我提供了一个基本的例子来帮助理解yield和generator机制

# coding: utf-8


def create_generator():  # 这是一个generator function
    mylist = [10, 20, 30]
    for e in mylist:
        yield e*e


def main():
    g = create_generator()  # 函数执行后执行的是yield而非return! 
    print g  # 因此不会有常规函数的返回结果, 而是直接给出一个generator对象(g)
    for val in g:  # g是一个generator, 实现了next方法. 可以g.next() 或者 next(g)来调用.
        print val


if __name__ == '__main__':
    main()

利用yield和next, send实现协程: 生产者消费者问题

协程的实现的核心关键点是:
(1) cpu的使用权能够在不同的任务之间流转;
(2) 每个任务在切换出去和切换回来的时候能够正确保存和恢复上下文环境. 换句话说, 之前任务执行的进度要保留着.

只要能够做到这两点, 就是一个合格的协程.

我们可以用协程来实现一个经典的生产者消费者问题, 代码如下:

# coding: utf-8
""""代码参考了廖雪峰博客网站中的代码""""

import time

def consumer():
    r = ''
    while True:
        n = yield r  # 最重要的一行代码, 出去和回来都最在这里
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        time.sleep(1)
        r = '200 OK'


def produce(c):
    c.next()  # 一开始通过调用iterator的next方法来开始一个cycle. 注意这里第一个从consumer yield返回的r是被扔掉的.
    n = 0
    while n < 5:  # Note: 协程最重要的特点, 与普通return函数调用的区别是, 它保存了函数内的临时变量的值, 也就是一个执行的进度, 而不是每次再进入该函数都重新创建!!!
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)  # send 关键字使得我们可以向协程中的某个任务传递数据
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()


if __name__=='__main__':
    c = consumer()
    produce(c)

代码中的注释比较详细, 通过PyCharm Debugger单步调试看一看程序是怎么走的, 结合注释后能对简单的协程有一个比较清晰的认识.

利用yield和next实现协程: 多个游戏世界同时运行问题

协程在实际中往往做的一个单进程多任务并行的应用.

假设我们要实现一个单进程但是能同时跑好多局游戏的服务器, 我们是可以利用协程+time.sleep()来实现CPU使用权的高效率流转.

这样的需求里, 每个正在进行的游戏局的小世界之间其实是完全相互独立的, 类似王者荣耀一样, 每个游戏世界之间不需要进行什么通信交流. 游戏小世界只有游戏初始化输入和结果输出的时候需要和大厅(或者是匹配服务器)做一个交互.

下面这份代码中, world.py表示的是我们游戏的小世界实例, 一局正在进行中的游戏就是一个小世界对象(小世界算是游戏开发中的一个术语). execution.py模块写的是我们协调执行各个world的调度代码, 并不复杂, 本质上就是一个队列, 一个任务类和一个装饰器. 最后, 我们还有一个demo.py, 里面就是放了一个让程序开始运行的main函数.

world.py

# coding: utf-8

"""运行中的游戏世界"""

import datetime
import time


class World(object):
    serial_num = 0

    def __init__(self):
        World.serial_num += 1

        self.id = World.serial_num
        self.count = 0
        self.game_over = False

    def tick(self):
        """跑游戏每个turn逻辑的tick函数"""
        self.count += 1
        print '{} -- tick {} is done {} - time:{}'.format(self.id, self.count,
                                                          datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                                                          time.time())

    from coroutines.worlds_co.execution import loop_task
    @loop_task
    def run_world(self):  # 是一个generator function
        turn = 0
        while not self.game_over:
            turn += 1
            print '-------Turn = w{}-{}--------'.format(self.id, turn)
            self.tick()
            yield 0  # 此处让出自己的执行权利
            #由于我们不需要向调用generator的地方传递参数, 因此这里的yield [value]的value可以随便写一个占位置就行

execution.py

# coding=utf-8

import functools
import time
from collections import deque


class Executor(object):
    """
    单例模式的Executor执行者
    """
    instance = None

    def __init__(self):
        self.dq = deque()

    @staticmethod
    def get_instance():
        if Executor.instance is None:
            Executor.instance = Executor()
            return Executor.instance
        else:
            return Executor.instance

    def execute(self):
        while True:   # Executor的永恒循环, 取出第一个任务, 执行. 任务执行完后又会把自己塞回去deque尾部.
            if len(self.dq) > 0:
                task = self.dq[0]
                task.run()
            else:
                print 'len = 0'


class Task(object):
    def __init__(self, generator=None, delay=0.0):
        super(Task, self).__init__()

        self.generator = generator
        self.delay = delay
        if delay:
            self.do_time = time.time() + delay
            print 'self.do_time: ', self.do_time

    def run(self):
        if (not self.delay) or (self.do_time and self.do_time <= time.time()):  # 如果时间到了, 就执行
            print 'run...'
            Executor.get_instance().dq.popleft()  # 执行的任务要从队列DQ中移除
            next(self.generator)  # 此处让generator从上次执行的地方继续往下再走一个周期(一个周期=yield出一个值的周期)
            next_task = Task(generator=self.generator, delay=0.4)
            Executor.get_instance().dq.append(next_task)
        else:  # 否则直接睡到可以执行的那个时刻
            sleep_time = self.do_time - time.time()
            print 'sleep_time:', sleep_time
            time.sleep(sleep_time)


def loop_task(func):
    """With executor, the decorated method becomes a looping task."""
    # 这里work_func必须要由原先函数返回的instance_func而不能直接是func, 这是因为func是没有绑定过一个具体对象的, 而instance_func绑定了.
    @functools.wraps(func)
    def wrapper(*args, **kwargs): # 由于generator是一个while循环, 因此这个wrapper只会被调用一次, 后面循环内放task进入executor的工作是Task类完成的
        generator = func(*args, **kwargs)
        next_task = Task(generator=generator, delay=2.0)
        Executor.get_instance().dq.append(next_task)
        print 'dq len =', len(Executor.get_instance().dq)
        return next_task  # 记得要返回原先函数的结果
    return wrapper

demo.py

import time

from coroutines.worlds_co.execution import Executor
from coroutines.worlds_co.world import World

if __name__ == '__main__':
    w1 = World()
    w2 = World()
    w1.run_world()
    time.sleep(0.2)
    w2.run_world()
    Executor.get_instance().execute()

不运用协程同样实现多个游戏世界同时运行问题

world.py

# coding: utf-8

"""运行中的游戏世界"""

import datetime
import time


class World(object):
    serial_num = 0

    def __init__(self):
        World.serial_num += 1

        self.id = World.serial_num
        self.count = 0

    def tick(self):
        """跑游戏每个turn逻辑的tick函数"""
        self.count += 1
        print '{} -- tick {} is done {} - time:{}'.format(self.id, self.count,
                                                          datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                                                          time.time())

    from coroutines.worlds_non_co.execution import loop_task
    @loop_task
    def run_world(self):
        self.tick()
        return self.run_world  # 这里非常tricky, run_world需要能够返回出本函数, 且是绑定了一个instance实例的函数

execution.py

# coding=utf-8

import functools
import time
from collections import deque


class Executor(object):
    """
    单例模式的Executor执行者
    """
    instance = None

    def __init__(self):
        Executor.instance = self
        self.dq = deque()

    @staticmethod
    def get_instance():
        if Executor.instance is None:
            Executor.instance = Executor()
            return Executor.instance
        else:
            return Executor.instance

    def execute(self):
        while True:
            # print 'executing...{}'.format(len(self.queue))
            task = self.dq[0]
            task.run()


class Task(object):
    def __init__(self, work_func=None, delay=0.0):
        super(Task, self).__init__()

        self.work_func = work_func
        self.delay = delay
        if delay:
            self.do_time = time.time() + delay
            print 'self.do_time: ', self.do_time

    def run(self):
        if (not self.delay) or (self.do_time and self.do_time <= time.time()):  # 如果时间到了, 就执行
            Executor.get_instance().dq.popleft()
            self.work_func()
        else:  # 否则再重新放回去队列中
            sleep_time = self.do_time - time.time()
            print 'sleep_time:', sleep_time
            time.sleep(sleep_time)


def loop_task(func):
    """With executor, the decorated method becomes a looping task."""
    # 这里work_func必须要由原先函数返回的instance_func而不能直接是func, 这是因为func是没有绑定过一个具体对象的, 而instance_func绑定了.
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        instance_func = func(*args, **kwargs)
        next_task = Task(work_func=instance_func, delay=2.0)
        Executor.get_instance().dq.append(next_task)
        return next_task  # 记得要返回原先函数的结果
    return wrapper

demo.py

# coding: utf-8

"""main: 不使用协程实现多个游戏世界同时运行"""

import time

from coroutines.worlds_non_co.execution import Executor
from coroutines.worlds_non_co.world import World

if __name__ == '__main__':
    world1 = World()
    world2 = World()
    world1.run_world()
    time.sleep(1.0)
    world2.run_world()
    Executor.get_instance().execute()

    # Note: 后续可以改成executor一个线程, 网络收发一个线程, 由网络收发线程生成world, 并且执行world.run_world()这个最开始的启动.

通过这样一个协程执行框架, 我们可以在一个python进程中并行地跑很多个游戏小世界, 实现单进程多局游戏的业务需求. 在一台服务器上, 我们可以打开多个python进程(一个进程占一个socket), 每个进程又跑多个协程, 这样能够最大化利用多核心大内存服务器的性能.

不过, 这份执行框架的代码, 不是完美的. 由于采用deque做队列, 潜在的隐患是当deque队列中的所有任务被执行完使得队列为空之后, 再调用dq[0]会出现一个Empty的错误.

我专门写了一个Python Queue的改良版队列叫NiceQueue, 读者们可以参考使用NiceQueue来改进现在这个程序. https://github.com/imcheney/NiceQueue

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容

  • Coroutine in Python 引言: 本文出自David Beazley 的关于协程的PPT,现在笔者将...
    LumiaXu阅读 1,600评论 4 8
  • 多进程/线程 最早的服务器端程序都是通过多进程、多线程来解决并发IO的问题。进程模型出现的最早,从Unix 系统诞...
    Newt0n阅读 15,258评论 9 69
  • python之进程、线程与协程 有这么个例子说他们的区别,帮助理解很有用。 有一个老板想开一个工厂生产手机。 他需...
    道无虚阅读 3,177评论 0 3
  • 生成器(Generator)yield表达式的使用生产者和消费者模型yield from表达式 协程(Corout...
    聪明叉阅读 16,822评论 1 32
  • 必备的理论基础 1.操作系统作用: 隐藏丑陋复杂的硬件接口,提供良好的抽象接口。 管理调度进程,并将多个进程对硬件...
    drfung阅读 3,537评论 0 5