有关协程的最经典示例之一就是生产者-消费者问题。在生产者-消费者问题中涉及两个函数,一个函数不断地产生值(比如,从一个文件中读取),另一个函数不断地消费这些值(比如,将值写入另一个文件中)。这两个函数可能形式如下:
function producer () while true do local x = io.read() -- 产生新值 send(x) -- 发送给消费者 end end function consumer () while true do local x= receive() -- 接收来自生产者的值 io.write(x, "\n") -- 消费 end end
为了简化这个示例,生产者和消费者都是无限循环的;不过,可以很容易地将其修改为没有数据需要处理时退出循环。这里的问题在于如何将 send
与 receive
匹配起来,也就是“谁占据主循环”问题的经典实例。其中,生产者和消费者都处于活跃状态,他们各自具有自己的主循环,并且都将对方视为一个可调用的服务。对于这个特定的示例,可以很容易地修改其中一个函数的结构,展开它的循环使其成为一个被动代理。不过,在其他的真实场景下,这样的代码结构改动可能会很不容易。
由于成对的 resume-yield
可以颠倒调用者与被调用者之间的关系,因此协程提供了一种无需修改生产者和消费者的代码结构就能匹配他们执行顺序的理想工具。当一个协程调用函数 yield
时,它不是进入了一个新函数,而是返回一个挂起的调用(调用的是函数 resume
)。同样地,对函数 resume
的调用也不会启动一个新函数,而是返回一个对函数 yield
的调用。这种特性正好可以用于匹配 send
和 receive
,使得双方都认为自己是主动方而对方是被动方。因此, receive
唤醒生产者的执行使其能生成一个数值,然后 send
则让出执行权,将生成的值传递给消费者。
function receive () local status, value = coroutine.resume(producer) return value end function send (x) coroutine.yield(x) end
当然,生产者现在必须运行在一个协程里:
producer = coroutine.create(producer)
在这种设计中,程序通过调用消费者启动。当消费者需要新值时就唤醒生产者,生产者向消费者返回新值后挂起,直到消费者再次将其唤醒。因此,我们将这种设计称为消费者驱动式的设计。另一种方式则是使用生产者驱动式的设计,其中消费者是协程。虽然上述两种设计思路看上去是相反的,但实际上他们的整体思想相同。
我们可以使用过滤器来扩展上述设计。过滤器位于生产者和消费者之间,用于完成一些对数据进行某种变换的任务。过滤器既是一个消费者又是一个生产者,它通过唤醒一个生产者来获取新值,然后又将变换后的值传递给消费者。例如,我们可以在前面代码中添加一个过滤器以实现在每行的起始处插入行号:
function receive (prod) local status, value = coroutine.resume(prod) return value end function send (x) coroutine.yield(x) end function producer () return coroutine.create(function () while true do local x = io.read() -- 产生新值 send(x) end end end) function filter (prod) return coroutine.create(function () for line = 1, math.huge do local x = receive(prod) -- 接受新值 x = string.format("%5d %s", line, x) send(x) -- 发送给消费者 end end) end function consumer (prod) while true do local x = receive(prod) -- 获取新值 io.write(x, "\n") -- 消费新值 end end consumer(filter(producer()))
代码的最后一行只是简单地创建出所需的各个组件,将这些组件连接在一起,然后启动消费者。
上述示例很容易联想到 POSIX
操作系统下的管道( pipe
)。使用管道时,每项任务运行在各自独立的进程中;而使用协程时,每项任务运行在各自独立的协程中。管道在写入者(生产者)和读取者(消费者)之间提供了一个缓冲区,因此他们的相对运行速度可以存在一定差异。由于进程间切换的开销最高,所以在一点在使用管道的场景下非常重要。在使用协程时,任务切换的开销则小得多(基本与函数调用相同),因此生产者和消费者可以手拉手以相同的速度运行。