開発

Async/Awaitとmapメソッドを組み合わせたときの動作

年末年始の休みを利用してなんとかElectronアプリを作り終えようと日夜励んでいる今日此の頃です。
基本的に昔のJavaScriptの知識しかないので正に旧石器時代の人間です。調べながら試しながら勉強し直しです。
あれこれTypescriptだとかgulpだとかに手を出しながらコードも現代的に書いてみようと試行錯誤しているわけです。
そんな中でも async / await を使った現代的な非同期処理の書き方なんて旧石器人からしたら憧れの的なわけです。
これがなかなか手強かったのでメモです。特にmapメソッドと一緒に使ったときは嵌りました。

環境

async / await は先進的な機能らしいのでまだ環境によって動作に違いがあるかもしれないので一応環境を書いておくとnode.js v6.5.0, Typescript 2.1.4です。tsconfig.jsonはこんな感じです。

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "noImplicitAny": false,
    "sourceMap": false,
    "lib": ["dom", "es2015.promise", "es5"]
  }
}

例題プログラム

まず基本的なasync / awaitのコードはこんな感じだと思います。1から5の合計を出力するプログラムです。
非同期処理はdelayして時間差を出しています。後から呼び出された方が早く終了します。

for文を使った例

function heavyProc(i) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(i)
    }, 1000 - i * 20)
  })
}

async function main1a() {
  let n
  let total = 0

  for (var i = 0; i <= 5; ++i) {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }

}
main1a()

Results
n 0
total 0
n 1
total 1
n 2
total 3
n 3
total 6
n 4
total 10
n 5
total 15

想定通りの結果です。非同期処理を呼び出しても順次実行されました。
次に2回連続で計算した場合はどうなるか。

2回計算するパターン

async function main1b() {
  let n
  let total = 0

  for (var i = 0; i <= 5; ++i) {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }

  for (var i = 0; i <= 5; ++i) {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }

}
main1b()


Results
n 0
total 0
n 1
total 1
n 2
total 3
n 3
total 6
n 4
total 10
n 5
total 15
n 0
total 15
n 1
total 16
n 2
total 18
n 3
total 21
n 4
total 25
n 5
total 30

これも想定通りの動きです。for文を使った場合は順番通り計算してくれています。
次に同じような処理を配列を使って処理させてみます。
配列には1から5までの数字が格納されておりmapメソッドで取り出して処理する流れです。

mapで単純に書き直してみる。

async function main2a() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  ary.map((i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

}
main2a()

Results
error TS1308: 'await' expression is only allowed within an async function.

for文からmapメソッドになっただけなので単純に動くかなと思ったのですがTypescriptで弾かれてしまいます。
ちゃんとasync function内でawait しているのになあ~と思いつつ色々調べてみるとどうやら直近の関数内でasync/awaitしていないので怒られていたということでした。確かに無名アロー関数内でawaitしています。つまりawaitからみて自分の所属する関数がasync になっていないといけないと。所謂スコープという奴ですね

asyncを書き足してみる

async function main2b1() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  ary.map(async (i) => { // awaitから見て一番内側の関数
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

}
main2b1()

Results
n 5
total 5
n 4
total 9
n 3
total 12
n 2
total 14
n 1
total 15

今度はトランスコンパイルされて実行もできるようになりました。
やったね。
と思ったら出力がfor文のときと違います。今度は実行結果の順序が違います。
ふむ、最終的な結果は同じなのですが順番通りやってもらいたい処理の時は困ります。
これはどういうことなのだろう、と試しにawaitを外して実行してみると

試しにawait外してみる

async function main2b2() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  ary.map( (i) => {
    n = heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

}
main2b2()

Results
n Promise { <pending> }
total NaN
n Promise { <pending> }
total NaN
n Promise { <pending> }
total NaN
n Promise { <pending> }
total NaN
n Promise { <pending> }
total NaN

想定通りおかしな動きになってます。この時のPromiseの状態はまだ評価されていないpendingのまま。非同期処理なので実行終了されていない状態のまま直ぐに次のコールバック関数が呼ばれる、が繰り返されて全ての処理がされてしまっているようです。
ん!?コールバック関数だと!!
そうか、mapメソッドがawaitされている訳ではないのだ。ということはawaitを戻して2回処理する動きにしたら出力も二重に見えるようになるはず。ということでやってみる。

二重になるはず。

async function main2c() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  ary.map(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

  ary.map(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

}
main2c()

Results
n 5
total 5
n 5
total 10
n 4
total 14
n 4
total 18
n 3
total 21
n 3
total 24
n 2
total 26
n 2
total 28
n 1
total 29
n 1
total 30

おお、想定通り出力が二重になっているように見えます。
一番はじめの例のforは順次に実行されるのでdelayしてコールバックの繰り返しになります。
シーケンスにしてみるとこんな感じだろうか。

for文のケース

一方mapはawaitしているのはコールバックだけなので1回目のmapループをすぐ抜けて2回目以降のmapループに突入、結果的にほぼ同じタイミングで1回目と2回目 以降のコールバックが実行されている。
シーケンス にしてみるとこんな感じだろうか。

map一回処理のみ
map二回処理

asyncを追加した直近の無名アロー関数がコールバックなのでawaitがかかるスコープがmapでは無くコールバックになる、というよく考えれば文法通りの動きでした。すっかりmain全体にかかっているものだと考えてしまっていた。
では、この並列処理をどうやってawaitすればよいのか。mapメソッド自体ににasync/awaitをかけるのは無理そう。ということはコールバックの方でなんとかできるだろうか。
調べてみると回答があった。もうそこに全て書いてある気がするのでリンク先を見てもらったほうが速いのだが一応続けると、コールバックの実体はPromiseなのでPromise.allで全てのPromiseを待ってから次に移ればよいと。つまりこんな感じに書く。

Promise.allだ!

async function main2d1() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0
  let prmsary

  prmsary = ary.map(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })
  await Promise.all(prmsary)

}
main2d1()

Results
n 5
total 5
n 4
total 9
n 3
total 12
n 2
total 14
n 1
total 15

await Promise.allはmainのasyncと紐付いてます。
出力自体に変化はないが2回処理してみると違いがわかります。

これで分離できるはず

async function main2d2() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0
  let prmsary

  prmsary = ary.map(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })
  await Promise.all(prmsary)

  prmsary = ary.map(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })
  await Promise.all(prmsary)

}
main2d2()

Results
n 5
total 5
n 4
total 9
n 3
total 12
n 2
total 14
n 1
total 15
n 5
total 20
n 4
total 24
n 3
total 27
n 2
total 29
n 1
total 30

よしっ!想定通り1回目と2回目の処理が別れて実行されています。
あとは個別のコールバックを順番通りに処理できるかどうか。
リンク先を見るとfor of 文でできそうです(ただしIEはサポートしていない)。forEachはダメということで動きだけ見ておきます。

forEachだと配列を返してくれないので。。。

async function main3a2() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  console.log('BREAK1')

  ary.forEach(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

  console.log('BREAK2')

  ary.forEach(async (i) => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  })

  console.log('BREAK3')

}
main3a2()

Results
BREAK1
BREAK2
BREAK3
n 5
total 5
n 5
total 10
n 4
total 14
n 4
total 18
n 3
total 21
n 3
total 24
n 2
total 26
n 2
total 28
n 1
total 29
n 1
total 30

最後にfor of文で書いてみます。結果は順番通り実行されています。
awaitがmainのasyncを見るようになるのでfor文の該当部分で処理待ちしてくれます。
というか最初のfor文の書き方に戻ってきた気がします (T_T)。
実際にこのような配列の中身の順序を気にするような処理があるのかわかりませんがパターンが決まっているのならその部分はthen()でメソッドチェーンで書いた方が良いかもしれません。あるいは、追記で言及しているfor-await-of文を使うことを検討しましょう。

for of文で書き直してみる

async function main3b() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  console.log('BREAK1')

  for (const i of ary) {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }

  console.log('BREAK2')

  for (const i of ary) {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }

  console.log('BREAK3')

}
main3b()

Results
BREAK1
n 1
total 1
n 2
total 3
n 3
total 6
n 4
total 10
n 5
total 15
BREAK2
n 1
total 16
n 2
total 18
n 3
total 21
n 4
total 25
n 5
total 30
BREAK3

追記1

追加で調べてみるとこういったケースはreduceを使うことがあるようです。
上記で確認したとおりmapを使った場合は順次実行されません。
一方、Array.reduce は左から右に順次実行する動きらしいのでmapよりは順序性があるようでmapよりreduceでやる方がよりグッドだから、らしいです。
ということでreduceで書いてみた例です。

reduceでも書いてみる

async function main4a1() {
  const ary = [1,2,3,4,5]
  let n
  let total = 0

  ary.reduce((promise, i) => promise.then(async () => {
    n = await heavyProc(i)
    total += parseInt(n)
    console.log('n ', n)
    console.log('total ',total)
  }), Promise.resolve())

}
main4a1()

Results
n 1
total 1
n 2
total 3
n 3
total 6
n 4
total 10
n 5
total 15

配列の中身が数字なので初期値がPromiseになるように第二引数をPromise.resolve()で入れて、後は第一引数のコールバックで処理を回すというやり方です。
確かに順番通りに実行されました。うーむ、しかし読みにくいというかArray.reduceの動きを理解していないと動きがわかりづらいです。
関数プログラミング的に書く場合はこちらが良いのでしょうがやはり慣れないと読みづらい。
更に調べてみるとp-iterationというモジュールを使うとある程度緩和できたり、ES2018ではfor-await-of文やAsync Iterationなどもっと直感的な記述ができそうな新機能が入ってきそうなので将来にはもっと洗練された書き方が期待されているようです。

追記2

上記で言及したfor-await-of文を試す機会があったので記事にしてみました。これを使うのが正解なのでしょう。

for await of を使ってみる – Mark Creators

以前async/awaitとmapメソッドに関して記事を書いたのですがJavaScriptも発展してasync/awaitに関連した構文、for await async, await, javascript, node.js, メモ

  

追記3

最近の検索キーワードで

‘await’ expressions are only allowed within async functions and at the top levels of modules.

‘await’ is only allowed within async functions and at the top levels of modules

で辿り着く方がいるようなのでなんでかな?と調べてみるとtop-level await (TLA)という機能が関連するようです。
従来は関数にasyncを追加してその中でawaitが機能するのですがTLAは、関数を使わなくてもawaitが使えてしまう機能のようです。モジュール限定の機能です。

詳しくは調べていないのですがこちらに詳細が記載されていました。

top-level awaitがどのようにES Modulesに影響するのか完全に理解する – Qiita

先日、TypeScript 3.8 RCが公開されました。TypeScript 3.8はクラスのprivateフィールド(#nameみたいなやつ)を始めとして、ECMAScriptの新機能のサポート…

  

件のキーワードはTypeScriptのエラーメッセージがTLAに合わせて従来のものが変更になったようなのでこのページにヒットしているようです。

Top-level `await` error message is extremely unclear · Issue #36036 · microsoft/TypeScript

// @target: es2017 // @module: esnext await 100; This gives the following error message: 'await' outside of an async function is only allowed at the top level of a module when '–module' is 'esnext…

  

個人的にはTLAの使い道が思いつかなかったのですがこちらにいくつかユースケースがありました。

GitHub – tc39/proposal-top-level-await: top-level `await` proposal for ECMAScript (stage 4)

top-level `await` proposal for ECMAScript (stage 4) – tc39/proposal-top-level-await

  

追記4

上記サンプルコードのように、awaitをfor文で使用したコードをEslintでチェックをかけると、no-await-in-loopのルールに引っかかるかもしれません。そもそも非同期処理は並列実行して処理時間を短くしたい意図があるはずですが、ループでいちいちawaitして処理待ちをしていたら意味が無い、ということのようです。このようなチェックに引っかかる場合は、処理を見直して本当にawaitが必要なのか確認したほうが良いでしょう。

管理人

Recent Posts

CanvaがSerif (Affinity) を買収

私は使ったことがないのですが名前はよく聞…

3週間 ago

Serifのスプリングセール – アドオンが50%オフ

Affinity Photoなどレタッチ…

1か月 ago

音声がロボットのようになるときの対処

リモート会議などでたまに相手の音声がおか…

3か月 ago

Serifのブラックフライデー – 全品40%オフ V1ユーザは更にお得!

恒例のブラックフライデーセールが始まりま…

5か月 ago

[rust] rayonで書き直してみました

前回のコードを元にrayonを使った処理…

5か月 ago

[rust] async-stdで書き直してみました

前回のコードをasync-stdで書き直…

6か月 ago