Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

非同期処理を理解する

はじめに

プロダクト開発部の荒川です。私事ではありますが、バックエンドエンジニアからiOSエンジニアへと転向しました。iOS開発は全くの未経験だったのですが、周りのメンバにも助けられつつ、なんとかやっていけています。サーバサイド、クライアントサイドの両側面から一つのアプリケーションを眺めることができて、非常に楽しく開発できています。

さて前置きはここまでにして、今回は非同期処理をまとめてみました。擬似コードはMDN Web Docsより参考にさせていただいております。

同期処理と非同期処理のキホン

非同期を説明する前に、同期処理について説明しておきましょう。 同期処理とはあるタスクを順番に実行する方式のことで、そのタスクが実行されている間は他のタスクが中断される方式です。*1

同期処理にはいくつかの欠点があります。最たる例としてディスクアクセスなどのI/O処理の場合、メモリアクセスと比べると非常に遅い事が多く、ファイルやソケットが読み出しを完了するまで処理を止める必要があるので、その間は他の処理をすることができません。

f:id:ad-sho-loko:20200202011639p:plain

一方で非同期処理はある処理を実行する間に他の処理を実現することができます。JavaScriptやSwiftなどのいわゆるクライアントサイド開発に携わる方は日常的に利用するはずです。非同期の実現方法にはいくつかの方法があるので、図は後ほどの実現方法の箇所にてご紹介します。

なぜ非同期処理が重要なのか

I/O処理に限らずブロックというのは、クライアントサイドにおいては大きな問題となります。アプリケーションのメインスレッドとしてUI専用のスレッド、ユーザからの操作を受け付けるためのイベントループの処理が動作しており、開発者が同スレッドにて処理をブロックするとイベントが処理できなくなります。結果としてユーザから見ると画面が止まったかのような体験となるので、UI以外の処理を同一スレッド上で実行するのを避けなければなりません。

もちろんサーバサイドにおいてもCPUを遊ばせておく必要性が低いので重要なテクニックであることには代わりありません。

非同期処理の文法たち

非同期処理を利用するための基本的な文法のうち有名なものを3つほどご紹介します。

callback(コールバック)

呼び出し元から呼び出し先に対して、完了後に実行したい処理を引数として渡すことで、非同期処理を扱う手法です。非同期処理の理解には、まずコールバックの考え方を理解することが最も重要だと思います。以降ではPromiseとasync/awaitによって文法上の欠点は改善されますが、良くも悪くもただの文法の違いに過ぎません。

引数に完了したあとの処理を送ることで、doSomethingが完了したあとに行いたい処理を指定します。今回の例の場合、doSomethingが完了したタイミングで引数に指定したdoSomethingElseが実行されるため、非同期な処理になっています。

紛らわしい点はコールバックで渡す処理の中身は同期/非同期かを問わないことです。コールバックというテクニックは同期/非同期処理のどちらにおいても適応できます。コールバックという言葉を使わずハンドラと呼ぶことあります。*2

そもそも厳密に定義していないのでアレですが、そもそもコールバック≠非同期、という点を区別することは難しいです。コールバックというのはテクニックの一種であり、非同期処理を実現するための一手法に過ぎません。

これらは混合しやすい概念なので注意していきたいですが、厳密に理解しようとしなくてもさほど問題にはならないと思います。*3 ちなみにコンピュータサイエンスの世界では『継続渡し』とも呼ばれます。

Promise

コールバックによる手法には、ネストが深くなりすぎるという欠点があります。これはコールバック地獄と呼ばれ、また例外の処理がやりにくいという問題があります。解決しようとする文法がPromiseです。Promiseとはデータ構造の一種で、非同期で実行したい処理を抽象化します。具体的なコードを見たほうが早いと思うので、コールバックのときと比較してみてください。

メソッドがPromiseというデータ構造を返すことによりコールバック地獄の問題を解決しています。ネストが薄くなったことが分かるかと思いますが、async/awaitはさらにこの構文を置き換えます。

async / await

async/awaitは非同期処理を同期処理のように書くことが出来る文法です。Promiseよりも同期的なコードを書く場合と似ています。

asyncというキーワードをメソッドに宣言した上で、awaitというキーワードを使うことで手続き型らしくも非同期的な処理を記述することが出来ます。注意してほしい点としては、あくまでも開発者が同期的に書き下すことができるだけであって、つまりはコンパイラ(インタプリタ)がよしなに非同期に書き換えているということです。これも文法上のレベルでの改善が主目的でしょう。

私もiOSエンジニア以前は.NETエンジニアだったので馴染み深い文法ではありますが、習得するのに時間がかかった覚えがあるので難易度は高めかもしれません*4。実はSwiftにもプロポーザルを提出しているので採用されるのが非常に楽しみです😆

非同期・ノンブロッキングの実現に向けて

非同期を利用するための文法の話を中心としていましたが、これらはあくまでも文法上の問題であり、非同期処理をいかに実現するか、という話とは別でした。

非同期処理というのは既に説明しましたが、呼び出し元が発行した操作をブロックさせずに、制御を戻すことができます。そこでブロックしない(=ノンブロッキング)という動作をどのように実現するのかを探っていきたいと思います。*5

呼び出し元から見たときに非同期のように見せるためには、当然ながらある種の工夫する必要があり、2つのテクニックを紹介します。

別スレッドによる実装

非同期で実行したい処理を呼んだタイミングでスレッドが分岐するパターンです。呼び出し元としては、あたかもブロックされていないように感じますが呼び出し先のスレッドはブロックされています。*6

f:id:ad-sho-loko:20200202011227p:plain

OSのスレッドを直に扱う言語の場合ではブロック専用スレッドを生成するコストは大きいですが、スレッドプールの工夫をするなどされていることが多いので、過度に気を使う必要はないかもしれません。更に言うとグリーンスレッドを採用する言語ではより軽量と言えるでしょう。

イベントループによる実装

node.jsではJavaScriptの実行はシングルスレッドにもかかわらず、イベントループと呼ばれる手法にてノンブロッキングを実現します。ただし厳密にはユーザ空間で実行されるスレッドが一つというだけですが、仕組みを簡単に説明します。

そもそもなぜI/O処理でブロッキングをしなければならないのでしょうか。I/Oの準備が完了するまで別スレッドにて待ち続けなくとも、準備完了状態になったかを定期的に確認すれば十分ではないか*7、という立場から解決を図ります。さらに用語を厳密にすれば、処理が完了したときに実行したいコールバックを事前に登録しておいたうえで、定期的にI/O処理が完了したかどうかを監視します。もし完了していれば登録していたコールバックを実行することでノンブロッキングであるかのように扱うことが出来るというわけです。実際には説明した内容よりもインテリジェンスな仕組みとなっており、興味のある方は Design overview — libuv documentationを参照してみてください。

f:id:ad-sho-loko:20200202010401p:plain

Node.js - イベントループ | node.js Tutorial

開発者がシングルスレッドでノンブロッキングを実現するために、カーネルの仕組みをフル活用するという構造になっているのでスレッド生成によるシンプルな方法に比べて理解が難しいと思います。epollやkqueueなどのあわせ技でこれらは実現されています。

I/Oアクセスはシステムコールで抽象化される以上、ユーザ空間にて制御できることは限られています。ノンブロッキングを実現する仕組みとAPIはカーネルが実装しなくてはならず、さらに進んだ理解のためには低レイヤの知識も必要になってくるはずです。

おわりに

非同期処理はシンタックスが容易になる一方で、深く理解せずとも使える抽象度の高い武器です。しかし性質を見極めた上で使えるようになってもらえると、非同期にすべき箇所がより明確に理解できるようになります。また非同期を支える技術やアーキテクチャについても理解を深めることで、エンジニアとして知見を深めることに繋がります。

非同期処理は難しい処理であり、今回それなりに調べた結果としての記事なのですが、もし誤り等あれば改訂しますのでご指摘いただけると嬉しいです。

参考


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

*1:厳密に定義することは難しい

*2:厳密に区別すると大変そうなので以後はコールバックを使います

*3:自分も頭の中では理解していると思うものの、説明しようとすると混乱する

*4:未だに完全に出来ていないかも

*5:ブロッキングとノンブロッキングという概念は、同期と非同期とは異なるものです。この辺は非常にややこしいです。

*6:スレッドという言葉を使っていますが、いわゆる最小の実行単位として捉えてください。例えばGoであれば実行単位はgoroutineですが、基本的には実行単位がプロセスだろうが、OS標準のスレッドだろうが、グリーンスレッドだろうが、話の論旨に影響はありません。

*7:もしくは完了通知をデバイスから非同期で受け取る

© Sansan, Inc.