#はじめに
非同期処理の理解にすごく困ったので、勉強した結果をまとめました!
基本的なところから書いたので、誰でも読んでいただけると思います!
ですが、自分の理解をまとめたものなので、もしかしたら間違っている箇所があるかもしれません...
なお、説明中のコードは全てnode.jsで実行しました。
#非同期処理とは
非同期処理というのは、簡単に言うと、処理をバックグラウンドで行うこと、です。
これに対して同期処理は、処理を1個1個順番に行っていきます。
例えば、下の画像は、自分がLGTMした記事一覧なのですが、これを表示するためには、
・データベースからLGTMした記事の情報を取得
・取得した結果を表示
という2つの処理を行う必要があります。
これを同期処理で行うと、データベースからデータを取得するように依頼して、結果が帰ってきたら画面を表示するという流れになります。でも、それだと結果が帰ってくるまでユーザは真っ白の画面を見ることになってしまいます...
非同期処理では、データベースからデータを取得するように依頼して、その処理をバックグラウンドでやってもらいます。バックグラウンドでやってもらっている間に、ページの枠組みだけ表示します。
リロードすると、一瞬だけ下の画像のようになると思います。真っ白な画面で待つよりも、こっちの方が良いですよね!
TwitterやYouTubeも、リロードすると初めの一瞬、枠組みだけ表示されます。非同期処理は意外と身近にありますね!
#コールバック地獄
上で見たように、非同期処理はバックグラウンドで処理を行うのですが、その分、順序を制御するのが大変になります。
突然ですが、問題です。下のコードの出力はどうなるでしょう?
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data}) //hoge.txtにはhogeと書いてあります
console.log(fileData)
コードを上から読むと、3行目で変数fileData
を定義して、4行目でhoge.txt
を読み込んで、fileData
にその結果を入れています。5行目でfileData
を出力しています。
うーん、出力結果はhoge
かなあ...
って思うと間違いで、このコードの出力結果はなんとundefined
になってしまいます。
なぜかと言うと、ファイル読み込みは、CPUにとって非常に時間がかかる処理で、非同期で実行されるようになっているからです。4行目で、ファイル読み込みのリクエストを開始して、5行目で結果を出力しようとしているのですが、5行目の時点では、バックグラウンドで行われているファイル読み込みがまだ終わっていないのです。
どうしたら期待通り動いてくれるでしょうか? setTimeout関数を使って、1秒間待ってから出力してみましょう。
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data}) //hoge.txtにはhogeと書いてあります
setTimeout(()=>console.log(fileData), 1000) //1秒間待ってからconsole.log(fileData)をします
このコードはちゃんとhoge
を出力してくれます。1秒間待っているので、その間にファイル読み込みの処理が終了しているんですね。(実際は1秒よりずっと早くファイル読み込みは終了します)
これは自分がハマったところなんですが、setTimeout関数の第一引数は関数です。つまり、
x setTimeout(console.log(fileData), 1000)
o setTimeout(()=>console.log(fileData), 1000)
です。
ちなみに、setTimeout関数中の()=>console.log(fileData)
のように、一定時間経ってから呼び出される関数のことをコールバック関数と呼びます。(コールバック関数は他の関数に引数として渡す関数、という説明がほとんどですが、非同期処理の文脈では、一定時間経ってから呼び出される、という解釈の方が、コールバックという言葉にも合っているかなあと思っています...)
もっと複雑な例を考えてみましょう。ファイルが3つあって、そのファイルに書かれているテキストを連結して出力する、という処理を考えます。
さっきと同様、同期処理(処理が1個1個順番に行われる)のお気持ちだと、コードは以下のようになります。
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data}) //hoge.txtにはhogeと書いてあります
fs.readFile('fuga.txt', 'utf-8', (err, data)=>{fileData+=data}) //fuga.txtにはfugaと書いてあります
fs.readFile('piyo.txt', 'utf-8', (err, data)=>{fileData+=data}) //piyo.txtにはpiyoと書いてあります
console.log(fileData)
hogefugapiyo
と出力して欲しいのですが、このコードの出力結果はもうお分かりの通り、undefined
となってしまいます。では、最後の出力をsetTimeout関数を使って書き換えてみましょう。
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data}) //hoge.txtにはhogeと書いてあります
fs.readFile('fuga.txt', 'utf-8', (err, data)=>{fileData+=data}) //fuga.txtにはfugaと書いてあります
fs.readFile('piyo.txt', 'utf-8', (err, data)=>{fileData+=data}) //piyo.txtにはpiyoと書いてあります
setTimeout(()=>console.log(fileData), 1000)
なんと今度は出力結果がhogepiyofuga
となってしまいました! (hogefugapiyo
となる時もあります)
これはどうしてかというと、hoge.txt
を読み込む非同期処理、fuga.txt
を読み込む非同期処理、piyo.txt
を読み込む非同期処理がほぼ同時にスタートしていて、どの非同期処理から終了するかが分からないからです。hogepiyofuga
が出力された場合、piyo.txt
を読み込む非同期処理が、fuga.txt
を読み込む非同期処理よりも早く終わってしまったということになります。
では、hoge.txt
の読み込みが開始してから1秒後に、fuga.txt
の読み込みを開始して、さらにその1秒後にpiyo.txt
の読み込みを開始して、さらにその1秒後に結果を出力、というコードを書いてみましょう。
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data})
setTimeout(()=>{
fs.readFile('fuga.txt', 'utf-8', (err, data)=>{fileData+=data})
setTimeout(()=>{
fs.readFile('piyo.txt', 'utf-8', (err, data)=>{fileData+=data})
setTimeout(()=>{
console.log(fileData)
}, 1000)
}, 1000)
}, 1000)
このコードであれば、hogefugapiyo
と正しく出力されます。ですが、このコード読みやすいでしょうか...? 非同期処理を記述するたびに、インデントが深くなってしまいます。これをコールバック地獄と言います。このように、setTimeoutで非同期処理の流れを制御するのには無理がありそうです。これを解決するために、考え出されたのがPromiseです。
#Promise
まず、Promiseの説明をしたいと思います。その後で、Promiseを使って、前述のコールバック地獄がどう解決できるのかを見ます。
Promiseは非同期処理の状態を持つオブジェクトです。
といっても何のことか分からないので、とりあえず、Promiseオブジェクトの作成から見ていきましょう! Promiseオブジェクトは例えば以下のように作成できます。
new Promise(resolve=>{
//非同期処理
//非同期処理が正常に終了したらresolveを呼ぶ
})
resolve=>{...}
というのは高階関数(関数を引数とする関数)になっています。なんで高階関数で初期化するんだろう...とか考えたのですが、よく分かりませんでした! とにかくこういう書き方をする!って覚えた方が良いかもしれません。
hoge.txt
を読み込むPromiseオブジェクトは以下のようになります。
new Promise(resolve=>{
fs.readFile('hoge.txt', 'utf8', (err, data)=>resolve(data))
})
先程、Promiseは非同期処理の状態を持つオブジェクトと言いました。この非同期処理の状態には3つあります。
・pending : 初期状態 処理が成功も失敗もしていない状態
・fullfilled : 処理が成功した状態
・rejected : 処理が失敗した状態
Promiseオブジェクトの作成時点ではpendingです。resolveを呼ぶと、pendingからfullfilledになります。(rejectedについては後で説明します)
hoge.txt
の読み込み結果を出力する先程のプログラム
import fs from 'fs'
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data}) //hoge.txtにはhogeと書いてあります
console.log(fileData)
これをPromiseを使って書き換えると、以下のようになります。
import fs from 'fs'
new Promise(resolve=>{
fs.readFile('hoge.txt', 'utf-8', (err, data)=>resolve(data))
}).then((fileData)=>console.log(fileData))
このコードはちゃんと、hoge
を出力してくれます。
ちょっと読みにくいので、Promiseオブジェクトを返す関数を定義してみましょう。
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>resolve(data))
})
}
fileRead('hoge.txt').then((fileData)=>console.log(fileData))
then
というのは、Promiseオブジェクトが持っているメソッドです。thenの中にはPromiseオブジェクトがfullfilledになった時に呼ぶ関数を書きます。
また、resolveに値を渡すと、その値をthenの中に記述した関数の引数にすることができます。この例で言うと、resolve(data)
のdata
が、関数(fileData)=>console.log(fileData)
の引数になっています。
resolveに渡すのは変数じゃなくても良くて、
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>resolve('ファイル読み込み終了'))
})
}
fileRead('hoge.txt').then((message)=>console.log(message))
このようにすると、ファイル読み込み終了
という文字列が、関数(message)=>console.log(message)
の引数になります。
では次に、非同期処理に失敗した時の処理を考えてみましょう。
ファイル読み込みの例で言うと、指定したファイルが存在しない時や、ファイルから取り出したテキストに不正な文字が入っていた時のことを考えます。非同期処理に失敗することを考慮する場合、Promiseオブジェクトは以下のように作成します。
new Promise((resolve, reject)=>{
//非同期処理
//非同期処理が正常に終了したらresolveを呼ぶ 失敗したらrejectを呼ぶ
})
reject
を加えると、hoge.txt
を読み込むPromiseオブジェクトは以下のようになります。
new Promise((resolve, reject)=>{
fs.readFile(path, 'utf8', (err, data)=>{
if (err) reject('ファイルが見つかりません')
else resolve(data)
})
})
resolveはPromiseオブジェクトの状態をpendingからfullfilledにするのでした。rejectはPromiseオブジェクトの状態をpendingからrejectedにします。Promiseオブジェクトの状態がrejectedになった時の処理は、thenメソッドの第2引数に書きます。
import fs from 'fs'
function fileRead(path) {
return new Promise((resolve, reject)=>{
fs.readFile(path, 'utf8', (err, data)=>{
if (err) reject('ファイルが見つかりません')
else resolve(data)
})
})
}
fileRead('hogehoge.txt').then((fileData)=>console.log(fileData), (errorMessage)=>console.log(errorMessage))
今回はhoge.txt
ではなく、存在しないhogehoge.txt
を読み込むようにしています。こうすると、reject
が実行されます。rejectに渡しているファイルが見つかりません
という文字列が、thenの第二引数である、関数(errorMessage)=>console.log(errorMessage)
の引数になります。
また、.then(func1, func2)
という記述は、.then(func1).catch(func2)
と書くこともできます。
import fs from 'fs'
function fileRead(path) {
return new Promise((resolve, reject)=>{
fs.readFile(path, 'utf8', (err, data)=>{
if (err) reject('ファイルが見つかりません')
else resolve(data)
})
})
}
fileRead('hogehoge.txt')
.then((fileData)=>console.log(fileData))
.catch((errorMessage)=>console.log(errorMessage))
こっちの方が読みやすいですね!
以上がPromiseの大まかな説明になります。
では、前に見た、3つのファイルを読み込んで、書かれているテキストを連結して出力する、という処理をPromiseを使って書いてみましょう。(簡単のためにresolveだけ考えます)これにはPromiseチェーンと呼ばれるものを使います。
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>resolve(data))
})
}
let fileData
fileRead('hoge.txt').then((data)=>{
fileData = data
return fileRead('fuga.txt')
}).then((data)=>{
fileData += data
return fileRead('piyo.txt')
}).then((data)=>{
fileData += data
console.log(fileData)
})
thenが連続で呼ばれていますね。 まず1個目のthenはfileRead('hoge.txt')
で返されるPromiseオブジェクトが実行するメソッドです。2個目のthenは1個目のthen内に書かれたfileRead('fuga.txt')
で返されるPromiseオブジェクトが実行するメソッドです。3個目のthenも同様です。
このように、then内の関数でPromiseオブジェクトを返すようにすることで、非同期処理を連続して書けるようになります。
setTimeoutで非同期処理を制御して書くと、
let fileData
fs.readFile('hoge.txt', 'utf-8', (err, data)=>{fileData=data})
setTimeout(()=>{
fs.readFile('fuga.txt', 'utf-8', (err, data)=>{fileData+=data})
setTimeout(()=>{
fs.readFile('piyo.txt', 'utf-8', (err, data)=>{fileData+=data})
setTimeout(()=>{
console.log(fileData)
}, 1000)
}, 1000)
}, 1000)
このように、コールバック地獄という、インデントが深くなっていく問題がありました。でも、今回Promiseを使ったことにより、
let fileData
fileRead('hoge.txt').then((data)=>{
fileData = data
return fileRead('fuga.txt')
}).then((data)=>{
fileData += data
return fileRead('piyo.txt')
}).then((data)=>{
fileData += data
console.log(fileData)
})
このように、スッキリ書けるようになりました。これなら、書きやすいし、読みやすいですね! Promise万歳!!
#async, await
async, awaitを使うと、Promiseチェーンをもっと簡潔に書くことができます。まず、非同期処理を順番に実行する関数を定義します。この時、async
という修飾子をつけます。
async function func() {
//非同期処理を順番に実行
}
asyncをつけた関数の中では、await <Promiseオブジェクト>
とすることで、非同期処理を順番に実行できます!
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>{resolve(data)})
})
}
async function fileReads() {
let fileData
fileData = await fileRead('hoge.txt')
fileData += await fileRead('fuga.txt')
fileData += await fileRead('piyo.txt')
console.log(fileData)
}
fileReads()
同期処理のように記述することができました! resolveに渡された値がawaitの戻り値になっていますね。
ここで気をつけたいのが、asyncをつけた関数の戻り値はPromiseオブジェクトになるという点です。初め、自分は上のコードを、
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>{resolve(data)})
})
}
async function fileReads() {
let fileData
fileData = await fileRead('hoge.txt')
fileData += await fileRead('fuga.txt')
fileData += await fileRead('piyo.txt')
return fileData
}
console.log(fileReads())
このように書いていたのですが、出力結果はhogefugapiyo
ではなく、Promise { <pending> }
になってしまいます。なので、thenメソッドを使って値を取り出す必要があります。
import fs from 'fs'
function fileRead(path) {
return new Promise(resolve=>{
fs.readFile(path, 'utf8', (err, data)=>{resolve(data)})
})
}
async function fileReads() {
let fileData
fileData = await fileRead('hoge.txt')
fileData += await fileRead('fuga.txt')
fileData += await fileRead('piyo.txt')
return fileData
}
fileReads().then((fileData)=>console.log(fileData))
#おわりに
非同期処理についての自分の理解をまとめてみました。まだ分かっていないところもたくさんあるのですが、基本は抑えられたかなと思っています。誤り等ございましたら、ご指摘いただけると幸いです。
#参考
・あんどうやすし著 ハンズオン JavaScript
・JavaScriptの非同期処理を理解する その1 〜コールバック編〜 https://knowledge.sakura.ad.jp/24888/
・小学生でもわかるasync/await/Promise入門【JavaScript講座】 https://www.youtube.com/watch?v=kbKIENQKuxQ