try-catch と await、あるいは AWS Lambda の handler と非同期処理について

次のようなコードを書いた場合、

async function handler(): Promise<any> {
  try {
    console.log('started')

    const p: Promise<any> = tick();  // started -> finished -> ticked
    // const p: Promise<any> = await tick();  // started -> ticked -> finished

    return p;

  } finally {
    console.log('finished')
  }
}

async function tick(): Promise<any> {
  return new Promise(r => setTimeout(r, 1000)).then(() => console.log('ticked'))
}

async function main() {
  await handler();
}

main();

Promise を返却する tick() 関数を await なしで呼び出すと、console に

started
finished
ticked

の順で出力される、これは意図していない。

一方で、await 付きで呼び出すと、console に

started
ticked
finished

の順で出力される、これは意図通り。

前者の場合、tick() の処理が終わる前に、handler() の処理が終わってしまうため、finally ブロックが実行されてしまう。 コンパイル時にエラーにならないのでハマりやすく注意が必要。 自分は、tick()Promise<T> を返却するのだから await を付けても付けなくても同じでしょ?と思い込んでた。

これを AWS Lambda でやらかしていて、次のようなコードを書いていた。

export const handler = async (event) => {
  try {
    console.log('started.');
    return myFatFunction();
  } finally {
    console.log('finished.');
  }
};

ある日、お客さんから「なんか処理が動いてないんだけど」と連絡があって。

調査ために単純化して、次のようなLambda関数を作成し、タイムアウトを15分にした上で実行してみた。 interval 処理は無限に続くので、3秒経った以降も ticked が表示されるのか否か。

export const handler = async (event) => {
  console.log('started.');
  const response = {
    statusCode: 200,
    body: JSON.stringify('Hello from Lambda!'),
  };
  
  setInterval(() => console.log('ticked', new Date()), 1000);
  
  await new Promise(resolve => setTimeout(resolve, 3000)); //
  console.log('finished.');
  return response;
};

結果は、、、、3秒経ったら ticked は表示されなくなった。

START RequestID ...
started.
ticked
ticked
ticked
finished.
END RequestID ...
. 
. 
. 
. 

ということで、前述の myFatFunction() は実行されない(または実行される保証がない)ことが判った。

ちゃんと await して処理の終了を待ってから離脱すること。

export const handler = async (event) => {
  try {
    console.log('started.');
    return await myFatFunction();
  } finally {
    console.log('finished.');
  }
};

型制約や非同期関数の扱いがもっと洗練されている言語ならこんなこと起こらないのだろうけど。

例えば C# の場合。

public static /*async*/ Task<string> Run()
{		
  try
  {
    Console.WriteLine("started");
    return Tick(); // starterd -> finished -> ticked
    //return await Tick(); // starterd -> ticked -> finished ※async 付けないとコンパイルエラー
  }
  finally
  {
    Console.WriteLine("finished");
  }
}

static async Task<string> Tick()
{
    await Task.Delay(TimeSpan.FromSeconds(3));
  Console.WriteLine("ticked"); 
  return "ticked";			
}

async 関数ならば、Task を返す関数の呼び出しに await を付ける事を強制できるのだけど、async 関数でなければ、await を付けずに Task を返却する関数を呼び出すことができる(できてしまう)。 冒頭のようなミスを、私は 起こしそうである。

では Kotlin なら。

suspend fun tick(): Unit {
  delay(1000L)
  println("ticked")
}

suspend fun handler(): Unit {
  try {
    println("started")
    tick()
  } catch (e: Exception) {
  } finally {
    println("finished")
  }
}

Kotlin の非同期な関数は JavaScript(Promise) や C#(Task) と違い、suspend fun として記述できる。 呼び出しに await などのキーワードは必要ないので、try-finally で囲んだ中の呼び出し方法に迷うことはない。 suspend の記述忘れは、必ずコンパイラさんが怒ってくれるので間違った考えのコードが実行されることはない。