■ 非同期処理とは
非同期処理を説明する前に、皆さんはCPU目線でIOにどのくらいの時間がかかっているか直感的に想像できますか。
Latency Numbers Every Programmer Should Know を参考に少しまとめてみます。
L1キャッシュの参照: 1 ns
L2キャッシュの参照: 4 ns
メインメモリの参照: 100 ns
SSDのランダムリード: 16,000 ns (16μs)
HDDのシーク: 2,000,000 ns (2ms)
httpでAPIにアクセス: 1,000,000,000 ns (1s)
メインメモリへのアクセスと比較すると、SSDへのアクセスには160倍、HDDへのアクセスには10,000倍もの時間がかかります。ネットワークアクセスともなれば10,000,000倍です。
人間には一瞬に感じられるIOも、CPUからすると非常に待ち時間が長く、クロック周波数が3GHzのCPUであれば1nsあたり、3回の命令を実行できるため、SSDのアクセス待ちでも48,000回の命令を実行できることになります。
つまり、シングルスレッドで動作するプログラムにおいて、IOを待っている間に処理を進められないというのはとても非効率なのです。
「IOの待ち時間に処理を進めないIO」を ブロッキングIO
、「IOの待ち時間にほかの処理を実行できるIO」を ノンブロッキングIO
といいます。
この、 ノンブロッキング
こそが非同期で、非同期処理とは、待ち時間に暇を持て余したCPUに別の処理を進めさせることなのです。
ここで、一つ注意してほしいのは 非同期処理
と 並列処理
は異なるということです。
非同期はあくまで待ち時間に他の処理を進めているだけで、同時に2つの処理を行っているわけではありません。
ちなみに、並列処理は同時に2つ以上の処理を同時に行うことで、並列数と同じ数のCPUコアを必要とします。
■ コールバックによる非同期処理
さて、非同期処理の理解ができたところで、JavaScriptで非同期処理を書いてみましょう。
ここで利用するのは setTimeout()
関数で、Pythonでいうところの sleep()
にあたります。ただし、 setTimeout()
はノンブロッキングなので、待ち時間の間に他の処理を実行できます。
1秒後にメッセージを表示するスクリプトを実装します。
※ setTimeout
は非同期なので、1秒待っている間に処理が進みます。最初に hello world
が出力されることに注目してください。
// setTImeout(sleep後に実行する関数, sleep時間(ms))
setTimeout(() => {
console.log("setTimeout finished!!")
}, 1000)
console.log("hello world")
// 出力
// "hello world"
// "setTimeout finished!!"
今度は、setTimeoutの更に1秒後にメッセージを表示するスクリプトを実装します。
setTimeout(() => {
console.log("1. setTimeout finished!!")
setTimeout(() => {
console.log("2. setTimeout finished!!")
}, 1000)
}, 1000)
console.log("hello world")
// 出力
// "hello world"
// "1. setTimeout finished!!"
// "2. setTimeout finished!!"
さらに一秒後にメッセージを表示する、、、
setTimeout(() => {
console.log("1. setTimeout finished!!")
setTimeout(() => {
console.log("2. setTimeout finished!!")
setTimeout(() => {
console.log("3. setTimeout finished!!")
}, 1000)
}, 1000)
}, 1000)
console.log("hello world")
// 出力
// "hello world"
// "1. setTimeout finished!!"
// "2. setTimeout finished!!"
// "3. setTimeout finished!!"
そう、コールバックによる非同期だと、非同期処理を連続して行う場合にどんどんネストが深くなってしまいます。 (コールバック地獄と呼んだりします)
■ Promiseオブジェクトによる非同期処理
# Promiseを利用した非同期処理
非同期処理の連結によるコールバックのネストを解決するのが Promise
オブジェクトです。
// コンストラクタ定義
new Promise((resolve, reject) => { /* 非同期処理 */ })
Promiseのコンストラクタは、コールバック関数を引数にとり、この関数内に非同期処理を記述します。
コールバック関数は、第一引数に resolve
(処理の成功を通知するためのコールバック関数), 第二引数に reject
(処理の失敗を通知するためのコールバック関数) を取ります
setTimeoutを実行するPromiseオブジェクトを定義してみましょう。
sleep()
が返すPromiseオブジェクトは setTimeout
を実行し、 id
が1以上であれば成功、1未満であれば失敗を通知します。
/* !-- この関数は以降の例でも利用します --! */
function sleep(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve(id) // 成功を通知
} else {
reject(id) // 失敗を通知
}
}, 1000)
})
}
resolve
と reject
はそれぞれ成功と失敗を通知する関数なわけですが、その通知を受け取るのが、Promiseオブジェクトのメソッド then
, catch
, finally
です。
-
then(callback)
resolve()
が呼び出されたときに、引数のcallback
が呼び出されます。resolve()
に渡した引数はそのままcallback
の引数となります。
一般的に成功時の処理はcallback
に実装します。 -
catch(callback)
reject()
が呼び出されたときに、引数のcallback
が呼び出されます。reject()
渡した引数はそのままcallback
の引数となります。
一般的に失敗時の処理はcallback
に実装します。 -
finally(callback)
resolve()
,reject()
どちらが呼び出されても、callback
が実行されます。
resolve
reject
の引数はcallback
に引き継がれないので注意
sleep(1).then((id) => { // 成功時の処理
console.log(`success!! (id=${id})`)
}).catch((id) => { // 失敗時の処理
console.log(`error... (id=${id})`)
}).finally(() => { // 成功時・失敗時共通の処理
console.log(`finished.`)
})
console.log("hello world")
// 出力
// hello world
// success!! (id=1)
// finished.
sleep(-1).then((id) => {
console.log(`success!! (id=${id})`)
}).catch((id) => {
console.log(`error... (id=${id})`)
}).finally(() => {
console.log(`finished.`)
})
console.log("hello world")
// 出力
// hello world
// error... (id=-1)
// finished.
# 非同期処理の連結
複数の非同期処理を連結するには then
のコールバック関数で新たなPromiseオブジェクトを返します。
sleep(1).then((id) => {
console.log(`success!! (id=${id})`)
return sleep(2) // 新たなPromiseオブジェクトを返却して、非同期処理を連結
}).then((id) => {
console.log(`success!! (id=${id})`)
return sleep(3)
}).then((id) => {
console.log(`success!! (id=${id})`)
}).catch((id) => {
console.log(`error... (id=${id})`)
})
console.log("hello world")
// 出力
// hello world
// success!! (id=1)
// success!! (id=2)
// success!! (id=3)
# 非同期処理を並行して実行 (Promise.all())
複数の非同期処理を並行して実行するには Promise.all()
メソッドを利用します。
Promise.all()
の引数には、並行して実行したいPromiseオブジェクトの配列を渡します。
Promise.all([
sleep(1),
sleep(2),
sleep(3),
]).then((response) => { // resolve() に渡した引数が配列形式でthenのコールバック関数の引数となります。
console.log("response: ", response) // [1, 2, 3]
}).catch((error) => {
console.log("error: ", error)
})
console.log("hello world")
// 出力
// hello world
// response: [1, 2, 3]
ちなみに、どれか一つでもエラーになると、 catch
のコールバック関数が実行されます。
Promise.all([
sleep(1),
sleep(0),
sleep(-1),
]).then((response) => {
console.log("response: ", response)
}).catch((error) => { // reject() に渡した引数がcatchのコールバック関数の引数となります。
console.log("error: ", error) // 0
})
console.log("hello world")
// 出力
// hello world
// error: 0
■ Promiseの処理を同期的に記述する (async/await)
Promiseの非同期処理は async/await構文を利用すると同期的に表現できます。
functionの前に async
を付与すると、関数は非同期関数(async function)とみなされ、戻り値をPromiseオブジェクトでラップして返すようになります。
戻り値がPromiseオブジェクトなので、 then
cache
finally
などのメソッドをチェーンすることができます。
非同期関数内では await
演算子が利用でき、 await
演算子を非同期処理(Promiseを返す処理)に付与することで、非同期処理の終了を待つことができます。(非同期関数内では終了を待ちますが、呼び出し元の処理は継続します。)
※ await演算子の戻り値はPromiseそのものではなく、Promiseに含まれた実際の結果値となります。
async function main() {
let s1 = await sleep(1) // await 演算子で非同期処理の終了を待ちます (呼び出し元の処理は継続)
console.log(`success!! (id=${s1})`) // 戻り値 s1 はPromiseではなく値そのもの
let s2 = await sleep(2)
console.log(`success!! (id=${s2})`)
let s3 = await sleep(3)
console.log(`success!! (id=${s3})`)
return [s1, s2, s3] // 非同期関数は戻り値をPromiseオブジェクトでラップした値を返します
}
main().then((ret) => {
console.log("ret:", ret) // ret: [1, 2, 3]
}).catch((err) => {
console.log("err:", err)
})
console.log("hello")
// 出力
// hello world
// success!! (id=1)
// success!! (id=2)
// success!! (id=3)
// ret: [1, 2, 3]
非同期処理がどこかでエラーになった場合は catch
のコールバック関数が実行されます。
async function main() {
let s1 = await sleep(1)
console.log(`success!! (id=${s1})`)
let s2 = await sleep(0)
console.log(`success!! (id=${s2})`)
let s3 = await sleep(3)
console.log(`success!! (id=${s3})`)
return [s1, s2, s3]
}
main()
console.log("hello")
// 非同期関数はPromiseを返すので then, catch をチェーンさせることができる
main().then((ret) => {
console.log("ret:", ret)
}).catch((err) => {
console.log("err:", err) // err: 0
})
console.log("hello")
// 出力
// hello world
// success!! (id=1)
// err: 0
■ XMLHttpRequestを使ってAPIに非同期でアクセスする関数を実装してみる
XMLHttpRequest(XHR)とは、JavaScriptでのAPIアクセスに昔から使われてきたオブジェクトです。
昨今では fetch()を利用するのが主流ですが、ここはあえて理解のために、原始的なXHRとPromiseを利用して、 APIに非同期でアクセスする関数を実装してみましょう。
function fetchToken(username, password) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
// xhrではステータスが変わるたびにこの関数が呼び出される。
// xhr.readyState
// 0: 初期化, 1: open呼び出し済, 2: send呼び出し済, 3: 一部の応答を取得, 4: すべての応答を取得
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) { // 4 = すべての応答データを取得済み
if (xhr.status === 200) {
resolve(xhr)
} else {
reject(xhr)
}
}
}
xhr.open( "POST", "http://localhost:8018/api/v1/login") // TODO: 利用できるURLを指定すること
let form = new FormData()
form.append("username", username)
form.append("password", password)
// リクエスト送信
xhr.send(form)
})
}
fetchToken("sys_admin", "admin").then((xhr) => {
console.log("success:", JSON.parse(xhr.responseText))
}).catch((xhr) => {
console.log("error:", JSON.parse(xhr.responseText))
})