🦔

非同期処理について

2023/02/11に公開

概要

非同期処理について、なんとなくわかった気になりプログラムを書いていたので知識の整理と学習内容をまとめようと思う。

前提知識

  • プログラミング言語はJavaScript(node.jsを使用し動かす)
  • エンジニア歴1年
  • メインはTypeScriptを使用し、Vue、Nodeでフロントとバックエンド開発を行なっている

非同期処理とは

非同期処理とは、プログラムの処理が完了してから次のプログラムを実行する(同期処理)ではなく、処理の完了を待たずに次の処理を実行することを言う。

同期処理
function 同期処理(){
  console.log("同期処理を行う")
}

consoloe.log("処理の開始");
同期処理();
consoloe.log("処理の終了");

// 処理の開始
// 同期処理を行う
// 処理の終了
非同期処理
function 非同期処理(){
  setTimeout(()=>{
    console.log("非同期処理を行う")
  },1000)
}

consoloe.log("処理の開始");
非同期処理();
consoloe.log("処理の終了");

// 処理の開始
// 処理の終了
// 非同期処理を行う
  • 使用場面
    • 外部との通信、重いファイル、画像の読み込み等を行う際に使用
  • メリット
    • 非同期処理が完了しなくても後続の処理を行うことができる
  • デメリット
    • プログラムの可読性が落ちてしまう

コールバック関数

Node.jsを使用して非同期処理を行う方法は複数存在するが、コールバック関数を使用するパターンが基本な実装パターンだと思う。(全てのコールバック関数が非同期処理ではない)
また、Node.jsのコールバックによる非同期処理には規約がある

  • コールバックがパラメータの最後に存在すること
  • コールバックの最初のパラメータが処理中に発生したエラー、2つ目以降のパラメータが処理の結果であること
コールバック関数
fs.readdir(
  ., 
  (err,files)=>{
    console.log("実行結果")
    console.log("err",err)
    conosle.log("files",files)
   }
)

// 実行結果
// err null
// files .直下に存在するファイル名が表示される

エラーハンドリング
コールバック関数のエラーハンドリングは同期処理のエラーハンドリングと少し異なる。
同期的なエラーハンドリングを行うとうまくいかない

同期的エラーハンドリング
function parseJSONAsync(json, callback) {
  try {
    setTimeout(() => {
      callback(JSON.parse(json));
    }, 1000);
  } catch (err) {
    console.log("エラーをキャッチ", err);
  }
}
parseJSONAsync("同期的エラーハンドリング", (result) => console.log("parse結果", result));

// -- 実行ログ --
// undefined:1
// 同期的エラーハンドリング

非同期エラーハンドリング
function parseJSONAsync(json, callback) {
  setTimeout(() => {
    try {
      callback(JSON.parse(json));
    } catch (err) {
      console.log("エラーをキャッチ", err);
    }
  }, 1000);
}
parseJSONAsync("aa", (result) => console.log("parse結果", result));

// -- 実行ログ --
// エラーをキャッチ SyntaxError: Unexpected token a in JSON at position 0

非同期処理では、実際に処理が動く際は、try..catchの外で実行されてしまうため同期的な処理でエラーハンドリングを行うとcatchすることができないので、エラーハンドリングする際は、非同期処理が実際に動く箇所でエラーハンドリングを行う必要がある。

コールバックヘル
複数の非同期処理を逐次的に実行する場合、非同期処理のコールバックの中で次の非同期処理を実行する必要があり、これが繰り返されることで可読性が低いコールバックヘルの状態になってしまいます。

コールバックヘル
asyncFunc1(input,(err,result)=>{
  if(err){
    // エラーハンドリング
  }
  asyncFunc2(result,(err,result)=>{
    if(err){
      // エラーハンドリング
    }
    asyncFunc3(result,(err,result)=>{
      if(err){
        // エラーハンドリング
      }
    })
  })
})

このプログラムはコードの分割を行うことで解決することができる

コールバックヘルの解消
function first(arg,callback){
  ayncFunc1(arg,(err,result)=>{
    if(err){
      return callback(err)
    }
    second(result,callback)
  })
}

function second(arg,callback){
  ayncFunc2(arg,(err,result)=>{
    if(err){
      return callback(err)
    }
    third(result,callback)
  })
}

function third(arg,callback){
  ayncFunc3(arg,(err,result)=>{
    if(err){
      return callback(err)
    }
    asyncFunc4(result,callback)
  })
}

// 全ての非同期処理を実行する
first(input,(err,result)=>{
  if(err){
    // エラーハンドリング
  }
  // ...
})

このようにコードを分割すればネストの深さは解消することができるが、エラーハンドリングが重複してしまう課題は残っている。

Promise

PromiseはES2015で導入された非同期処理の状態と結果を表現するオブジェクトです。
コールバックヘルの解消で書いたコードをPromiseで置き換えるとよりスッキリとしてコードを書くことができる。

Promise

asyncFunc1(join){
  return new Promise((resolve,reject)=>{
    try{
      resolve(join)
    }catch(err){
      reject(err)
    }
  })
}

asyncFunc2(join){
  return new Promise((resolve,reject)=>{
    try{
      resolve(join)
    }catch(err){
      reject(err)
    }
  })
}

asyncFunc3(join){
  return new Promise((resolve,reject)=>{
    try{
      resolve(join)
    }catch(err){
      reject(err)
    }
  })
}

asyncFunc1("Hello")
	.then(asyncFunc2)
	.then(asyncFunc3)
	.catch(err => console.log(err))

それそれの関数を宣言した後の呼び出しはスッキリしていてかつ、エラーハンドリングが呼び出し元で行えているためコードの可読性が上がっている。

Promiseは引数に関数をパラメーとし、関数にはresolve,rejectの2つの引数で実行される。
Promiseインスタンスは状態を保持することができる。

インスタンス作成時 resolve実行時 reject実行 処理の完了後
peding fulfilled rejected settled

Promiseインスタンスの状態が、fulfilledまたはrejectedになった際にthen(onFulfilled,onRejected)の中のコールバックが実行される。

promise.then(
  // onFulfilled
  value => {
    // 成功時の処理
  },
  // onRejected
  err => {
    // 失敗時の処理
  }
)

これらのコールバック関数はどちらも省略可能です。
thenの戻り値は新しいいPromiseインスタンスを返すので、thenを使用して非同期処理を逐次的に実行することが可能となり、元々のPromiseインスタンスは変更されない。
また、onRejectedを省略した場合、catchでエラーハンドリングすることが可能である。
Promiseインスタンスにはfinall()メソッドも持っている。
finall()はtry...catchと同様の機能を持っている。

Promiseを使用した非同期処理の並行実行
then()を使用すると複数の非同期処理の逐次実行が簡単に実装することができる。
一方複数の非同期処理を並行実行するのに便利なAPIが、Promiseのスタティックメソッドに用意されている。

  • Promise.all()
    • 引数のPromiseインスタンスが全てfulfilledになった際にfulfilledになり、1つでもrejectedになった場合、rejectedになる。
  • Promise.race()
    • 引数のPromiseインスタンスが1つでもsettledになると、その他のPromiseインスタンスを待たずにそのインスタンスと同じ状態になる。
  • Promise.allSettled()
    • 引数のPromiseインスタンスが全てsettledになった際にfulfilledになる

Promiseのメリット

  • 複数の非同期処理をまとめて扱うことができる
    • then()を使用した逐次処理
    • Promise.all()を使用した並行実行
  • 非同期の状態、結果をオブジェクトとして表現することができる
    • 関数の引数や戻り値、変数に割り当てることが可能

Promiseのデメリット

  • インスタンスがsettledになってしまった場合、それ以上状態が遷移することがないので、結果が複数回に分割して帰ってくる処理には適していないです。
    • ファイルを読み込む場合

Async/await

async/await構文はES2017で導入された仕様で、簡潔かつ直感的に非同期プログラムミングを行うことができる。

使い方

async/await
async function asyncFunc(input){
  try{
    const result1 = await asyncFunc1(input)
    const result2 = await asyncFunc2(input)
    const result3 = await asyncFunc3(input)
    const result4 = await asyncFunc4(input)
    // ...
  } catch(e){
    // エラーハンドリング
  }
}

関数の前にasyncを記載し、関数の前にawaitを記載する。
awaitの後ろにpromiseインスタンスを渡すことで、関数内の処理が一時停止し、その解決に伴って再開する。
Promiseインスタンスの解決された値がawaitによって返される。
async/await構文のメリットは、同期処理のように可読性の高い非同期処理を記述することができることだ。
then()やcatch()に渡すコールバック関数によるスコープ、ネスト発生を防ぐこともできる。
並行実行には、Promise.all()などのスタティックメソッドと組み合わせる必要がある。
for await..ofをしようすることで、非同期に反復処理を行うことが可能。
これは非同期の処理を順番に実行するために使用するが、順番が関係ない場合処理が止まってしまう分パフォーマンスが悪くなるので、Promise.allなどのスタティックメソッドを使用するべきである。

最後に

非同期処理についてわかっていることをヅラヅラ記載していったが、まだまだ記載不足、修正箇所があるかと思うので今後こちらの記事はブラッシュアップしていきたい。

参考書籍

Discussion