Ruby
JavaScript
async
coroutine
es7

Ruby で ES7 の async/await を超絶簡単に実装する

More than 1 year has passed since last update.


Introduction

突然ですが、皆さん async/await は知っていますか!?

async/await といえば ES7 での仕様策定に向けて議論が進められている ECMAScript の新機能で、非同期処理を同期処理っぽく書く事ができます(ルーツは C# のasync/await らしいです)。

async/await について理解を深める為に、実際に(現在提案されてる仕様での)使い方を見てみましょう。

以下は http://wiki.ecmascript.org/doku.php?id=strawman:async_functions からの抜粋です。

  1 var request = require('./request.js');

2 var headers = { 'User-Agent': 'lukehoban', 'Authorization': 'token 665021d813ad67942206d94c47d7947716d27f66' };
3
4 // Promise-returning asynchronous function
5 async function getCollaboratorImages(full_name) {
6 // any exceptions thrown here will propogate into try/catch in callers - same as synchronous
7 var url = 'https://api.github.com/repos/' + full_name + '/collaborators';
8 // await a promise-returning async HTTP GET - same as synchronous
9 var [response, body] = await request({url: url, headers: headers});
10 return JSON.parse(body).map(function(collab) {
11 return collab.avatar_url;
12 });
13 }

ちょっと複雑すぎる例になってますが、注目すべきは 9行目var [response, body] = await request({url: url, headers: headers}); です。この行では、request({url: url, headers: headers}) で http リクエストを投げて、その結果を response, body に代入しています。さらに続く10行目以降で、 body が関数の返り値の生成に使われています。

このコードだけを見ていると、9行目では同期的に http request が投げられ、その response が返ってくるのを待って(Network IO でブロックして)、 resopnse, body の代入が行われている様に見えます。しかし、実際にはそうではありません。実は、上記のコードは以下の様なコードと等価になります。

// Promise版

1 var request = require('./request.js');
2 var headers = { 'User-Agent': 'lukehoban', 'Authorization': 'token 665021d813ad67942206d94c47d7947716d27f66' };
3
4 function getCollaboratorImages(full_name) {
5 var url = 'https://api.github.com/repos/' + full_name + '/collaborators';
6 return request({url: url, headers: headers}).then(function(value) {
7 var [response, body] = value;
8 JSON.parse(body).map(function(collab) {
9 return collab.avatar_url;
10 });
11 })
12 }

Promise が使われていて、見慣れたコードになっていますね。ポイントは getCollaboratorImages の返り値が Promise オブジェクトになっている事、また request({url: url, headers: headers}) が返す Promise オブジェクトに対しては then で続きの処理を書いている事です。then によって callback の処理を登録している為、完全に非同期に実行されるコードとなっています。

Promise を使ったコードと async/await を使ったコードが、なぜ等価となるのでしょうか?

鍵になるのは、ES6で導入された Generator という機能です。


ES6 でも Generator と Promise を使うと async/await が実装できる

実は、Generator と Promise を組み合わせる事で async/await (と同等の機能を果たす関数)を実装する事が出来ます。

これに関しては、 ES6 Generatorを使ってasync/awaitを実装するメモ が詳しいので興味が湧いた人は読んでみてください。

上記記事で注目すべきなのは、Generator をコルーチンとして使っていて、Promiseと組み合わせていることです。この2つが async/await を実装する為に必要なパーツとなります。

コルーチンは、雑に説明すると「処理の中断や再開が可能な手続き処理」を指します。有名どころだと、Lua や C# が言語レベルでコルーチンをサポートしています。コルーチンの呼び出し側とコルーチン側で制御を行き来する事が出来て、その際に値の受け渡しもできるのが特徴です。

逆に言えば、コルーチンと Promise があれば async/await は実装できるわけです。さて、ここで本題にいきたいと思いますが、 Ruby で コルーチンといえば Fiber ですよね?

という事で、Fiber を使って、簡単に async/await を実装してみました。


Ruby で Fiber を使って async/await を実装する

それでは、Ruby で async/await を実装してみたいと思います。

今回はサンプル実装なので、例外の発生は考慮せず、Promise も以下の様な initialiizethen だけが定義された簡単なものを考えます。

  1 class Promise

2 def initialize(callback)
3 @callback = callback
4 end
5
6 def then(resolve = ->() {}, reject = ->() {})
7 @callback.call(resolve, reject)
8 end
9 end

これは、例えば以下の様に使います。

  1 require 'eventmachine'

2
3 def sleep(sec)
4 Promise.new(->(resolve, reject) {
5 EM.add_timer(sec) do
6 resolve.call('you sleep ' + sec.to_s)
7 end
8 })
9 end
10
11 EM.run do
12 sleep(0.3).then(->(value) {
13 p value
14 })
15 p 'start'
16 end
17 #=> "start"
18 #=> "you sleep 0.3"

sleep メソッドの 返り値である Promise オブジェクトに対して、then で続きの処理(callback)を渡しています。sleep の動作が「渡された数字の秒数だけ待ってから、then で登録された callback を実行する」というものになっている為、上記のコードを事項すると15行目の p 'start'"start" が出力されてから、13行目の p value"you sleep 0.3" が出力されます(尚、即時 return してから登録した callback を実行するという動作を実現する為に EventMachine を使っています。EM.run に渡したブロックの中では様々な non-blocking api が使えて、例えば上記の例では JS の setTimeout 的な振る舞いをする EM.add_timer を使っています)。

さらに Promise を返す別のメソッドとして以下の様な decorate_message を定義して、sleep に続けて使う func を考えます(僕が雑に定義した Promisethen メソッドは Promise オブジェクトを返さない為、chaining はできてませんが気にしないでください)

  1 def decorate_message(message)

2 Promise.new(->(resolve, reject) {
3 http = EM::HttpRequest.new('http://google.com/').get
4 http.callback {
5 resolve.call(message + ': ' + http.response[0..100])
6 }
7 })
8 end

# use Promise

1 def func(time)
2 sleep(time).then(->(message) {
3 p message
4 decorate_message(message).then(->(decorated_message) {
5 p decorated_message
6 })
7 })
8 end
9
10 EM.run do
11 func(0.3)
12 p 'start'
13 end
14 #=> "start"
15 #=> "you sleep 0.3"
16 #=> "you sleep 0.3: <HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>302 Moved</TITL"

この func を今から定義する async, awai メソッドをつかって書き換えると、以下の様になります。sleep や `decorateなんだかそれっぽく見えますね!!

# use async/await

1 async :asyncFunc do |time|
2 message = await sleep(time)
3 p message
4 decorate_message = await decorate_message(message)
5 p decorate_message
6 end
7
8 EM.run do
9 asyncFunc(0.3)
10 p 'start'
11 end
12 #=> "start"
13 #=> "you sleep 0.3"
14 #=> "you sleep 0.3: <HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>302 Moved</TITL"

次に、ここで使った async メソッドと await メソッドの実装を見てみたいと思います。


Fiber を使った async/await の実装

一番シンプルな例として、 Promisethenresolve だけが指定されるケースを考える(かつ例外の発生も考慮しない)と、以下のコードで async/await が実現できます。たった19行の短いコードです。

# implementation of async/await

1 def async_internal(fiber)
2 chain = ->(result) {
3 return if result.class != Promise
4 result.then(->(val) {
5 chain.call(fiber.resume(val))
6 })
7 }
8 chain.call(fiber.resume)
9 end
10
11 def async(method_name, &block)
12 define_method method_name, ->(*args) {
13 async_internal(Fiber.new { block.call(*args) })
14 }
15 end
16
17 def await(promise)
18 Fiber.yield promise
19 end

注目するポイントは 16行目Fiber.new と、 5行目fiber.resume21行目Fiber.yield です。Fiber はコルーチンの機能を提供するためのモジュールで、 Fiber.new にコルーチンにしたい処理をブロックで渡して使います。

Fiber の挙動は、以下のコードを見るとイメージしやすいかもしれません。Fiber.new に渡した処理は、生成した fiber に対して resume を呼び出す事で実行されます。以下の例では、 13行目fiber.resume(i) を実行しており、このタイミングで "init fiber" が出力されます。ただし、5行目Fiber.yield が呼ばれると、処理がストップして制御は 13行目 に戻ります。この様に、コルーチンの「処理を途中で抜ける」機能は Fiber.yield によって実現されます。

# fiber example

1 fiber = Fiber.new do
2 p 'init fiber'
3 n = 0
4 loop do
5 result = Fiber.yield(n)
6 p "after yiled: #{result}"
7 n += 1
8 end
9 end
10
11 i = 10
12 4.times do
13 result = fiber.resume(i)
14 p "after resume: #{result}"
15 i += 1
16 end
17 # >> "init fiber"
18 # >> "after resume: 0"
19 # >> "after yiled: 11"
20 # >> "after resume: 1"
21 # >> "after yiled: 12"
22 # >> "after resume: 2"
23 # >> "after yiled: 13"
24 # >> "after resume: 3"

Fiber.yield には引数を渡す事が出来て、渡した引数は fiber.resume の返り値となります。逆に、fiber.resume に引数を渡す事も出来て、この場合は Fiber.yield から処理を再開する際にその返り値として使う事ができます。返り値の関係は少しややこしいですが、あくまで「Fiber.yield が通常の return に相当するもの(よって fiber.resume の返り値になる)」、「fiber.resume の2回目以降の呼び出しは処理の再開を意味していて、再開時に初期値を渡す事ができる」みたいに考えておくと良いと思います。(よく見ると fiber.resume の最初の引数は使われていない事がわかると思います)

Fiber を使う事で、 Fiber.yieldfiber.resume で制御を行ったり来たりしながら協調して作業を進める事ができます。

ここで async, await の実装に戻ると、await はただ Fiber.yield を実行しているだけ、また async も実行したい処理をブロックとして受け取って Fiber オブジェクトを作り出し、async_internal に渡しているだけである事がわかります。

async_internal がメインのロジックですが、ここもそれほど複雑な事はしていません。chain という名前で再帰的に実行される Proc オブジェクトを作り、メソッドの最後に chain.call を呼ぶことで再帰処理を開始しています。

chain.call の引数に fiber.resume を書いているのがポイントで、fiber.resume によってコルーチンの処理が開始され、Fiber.yield(つまり await)が呼ばれるまで処理が続きます。await に出くわすとfiber.resume を呼び出した先に制御が返りますが、その際に返り値は Promise となっていて、それが chain の引数になります。chain の中では引数に対して then で続きの処理を書いて、再び fiber.resume を呼び出します。この時、fiber.resume に初期値として渡した値がコルーチンの処理の再開時に await の返り値として取得できます。

以上の様なフローによって、asyncawait を使う事で同期的処理の様に書きながらも非同期処理を実現する事ができました。1


まとめ


  • ES7 での仕様策定が進められている async/await を使うと、非同期処理を同期処理っぽい syntax で記述できる

  • async/await は、Generator をコルーチンとして利用し、さらに Promise と組み合わせる事で実現できる

  • コルーチンは、 JavaScript 以外にも Ruby や Lua など様々な言語で提供されている機能である為、様々な言語で async/await は実現できる(と思う)


参考資料





  1. Ruby 版 asyncPromise を返さないとか、Promise がそもそも chaining できないとか細かな差異はいろいろありますが、その辺は API をどうするかという問題なので、適宜書き換えれば調整はできると思ってます。