🪡

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

2024/02/07に公開3

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

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.');
  }
};

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

Discussion

Akihiro MATOBAAkihiro MATOBA

ん? ソースに記述した通りの動きになっているように見えたのですが、どのへんが「これは意図していない」のでしょう?
最後の一文で矛先が外に向いてしまっているのがすこしひっかかります。

amay077amay077

コメントありがとうございます。
「意図していない」のは、

function handler() {
  try {
    console.log('started')
    return tickPromise();
  } finally {
    console.log('finished')
  }
}

のコードにおいて、
tick() を try〜finally で囲んだのだから、finally は tickPromise() が実行完了してから呼び出されるだろう
という 私の期待 に反した、の意です。

これは私が実行の仕組みを理解していなかったので、
「ソースに記述した通りの動きになっている」
と言うのはまったくその通りです。

最後の一文で矛先が外に向いてしまっている

そんな先の尖ったものは出していないつもりなのですが😓、別の方と Kotlin なら〜 みたいな会話をしてました。

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

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

非同期処理は suspend fun でしか呼び出せないので、JavaScript 等のように await 有りでも無しでも呼び出すことができる=ミスる事がなくて良いなあ、と思いました。

Akihiro MATOBAAkihiro MATOBA

なるほど Kotlinですか、使ったことなかったので急ぎ環境構築して調べてみたところ、「意図していない」ように実行させるには以下のように書く必要があるのですね!
仰る通り、coroutineScope内かどうか明示させるのと、無邪気に new Promise できるのとでは差がありますね。勉強になりました、ありがとうございます!!

import kotlinx.coroutines.*

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

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