非同期処理考察1 - ReactでAPIを叩くまでの道
今回はReactまで至りません。多分3回分ぐらい書いてやっと届くと思います…。
本エントリを書いている時点で、私はC#のasync/awaitキーワード以降の思想を理解していません。理解する過程を残すことに意味があると思うので敢えてこの状態から書きます。
内容に不正確な点が混じるかもしれませんので、怪しいと思ったら鵜呑みにしないでググってください。もし間違っていたらコメントいただけると幸いです。 後述する通り、題材が題材だけに正確なことを書ける自信が全くありません。
お約束ですが、本当にすべてを説明しようとすると本が一冊書けてしまうのでC#での実装を例に実用の範囲で書きます。建前は備忘録ですので。
動機
ReactでAPIサーバと通信する上で、一般にはredux-thunkやredux-sagaのような非同期処理専門のサードパーティー製ライブラリを使う方法が世の中では推奨されています。 これらのライブラリを思考停止で使うのが一番良いのかもしれませんが、使い方を見てみると「ここまでしないといけないんか」と言わざるを得ない面倒くささで、正直やる気がもげました。
というわけもあって、Reactのコンポーネントの中(例えばonClickハンドラ中)で以下のようにaxiosを使ってAPIサーバと通信する手法を採用しようとしています。
const ActivityViewButton: React.FC = () => { const [dataList, setDataList] = useState([]); const updateActivityList = () => { const activityApiEndpointUri = API_BASEURL + 'activities/'; axios.get(activityApiEndpointUri) .then((response) => { setDataList(response.data); }) } const handleClickOpen = () => { updateActivityList(); }; return ( <div> <IconButton onClick={handleClickOpen} edge="start" className={classes.menuButton} color="inherit" aria-label="menu"> <TimelineIcon /> </IconButton> </div > ); }
この実装だと非常に直感的に書けると思っていますが、気になるのが世の中であんだけ口うるさく「非同期処理はmiddleWareで」と言われているにはそれなりの事情があるのではないか、という点です。
この点を見誤って、一見容易なアーキテクチャを採用してあとから大火傷することだけは避けたいので、この「それなりの事情」を十分に考察してやろうというのが本記事の動機です。
理解の流れ
非同期処理についての理解は以下の順序で行います。
- OSレベルのスレッド操作(C言語のpthread)
- C言語の思想を受けたスレッド操作(C#のThread等)
- スレッド操作を隠蔽した非同期処理(C#のasync/awaitキーワード等)
- スレッド操作を隠蔽し、内部的にもスレッドを使わない非同期処理(js、tsのasync/await、promise)
今回は2以降の思想を解説します余裕があればいつか1にも言及します。
大事故を避けるために(少し脱線します)
しばしば「非同期処理は人類には早すぎた」とプロのエンジニアでさえ評価します。
最近の言語では非同期処理を凄まじく簡単にかける機能が流行っており、巷ではasync/awaitやTaskオブジェクト、Promiseクラスを使った一見簡単な非同期処理が飛び交っています。 それらの一見簡単な非同期処理は容易に不具合を生み、実装の複雑さを爆発的に増大させます。
また、スレッドを直接操作する手法(前項の1、2の手法)をエレガントでないとして嫌う記事をよく見かけるようになりました。非同期処理の入門として書かれているページでさえそのような記載が少なくありません。
これは持論ですが、2を理解しないまま3や4の思想を使って実装するのは非常に危険ですので避けるべきですし、プログラマのレベルの揃わないチームでは3の思想はそもそも使うべきではないとも考えます。
一方、jsやtsではそもそもスレッドの概念がないため1や2の思想がそのままでは通用しません。4までちゃんと理解する必要があります。
非同期処理とは
プログラムを書いていると、「なにか重たい処理をしていてもボタンが効いてほしい」といったケースがよく発生します。
例えば、Webブラウザがファイルダウンロードをしている間は別の操作ができなくなってしまうとストレスが溜まって仕方がないと思います。
こういった問題を解決するため、複数の処理を非同期的に(≒他の処理が終わる前に他の処理を開始できるように)実行する実装を行います。 このことを 非同期処理 と呼びます。
非同期処理の概要 - マルチスレッド(1、2の思想)
同期処理と非同期処理の例を下図に示します。
図だけだと理解しにくいので、続けてコードを混ぜて説明していきます。
実装サンプル
以下は C# .NetFramework 4.7.2 による Windows Forms 実装です。
開発環境はVisual Studio 2019 Communityを想定します。
同期処理の実装をダウンロードする (gitlab.com)
非同期処理の実装をダウンロードする(不完全版) (gitlab.com)
同期処理の例と課題
同期処理の実装は以下の感じになります。
private void heavyTaskA() { // 10秒かかる処理の例 System.Console.WriteLine("Start Heavy Task A..."); Thread.Sleep(10000); System.Console.WriteLine("End Heavy Task A..."); } private void heavyTaskB() { // 5秒かかる処理の例 System.Console.WriteLine("Start Heavy Task B..."); Thread.Sleep(5000); System.Console.WriteLine("End Heavy Task B..."); } private void button1_Click(object sender, EventArgs e) { System.Console.WriteLine("Normal Task1"); // 10秒かかる処理を開始する ←終わるまで待っている点に注意! heavyTaskA(); // 5秒かかる処理を開始する ←終わるまで待っている点に注意! heavyTaskB(); System.Console.WriteLine("Normal Task3"); } private void button2_Click(object sender, EventArgs e) { System.Console.WriteLine("Normal Task2"); }
この実装の場合、button1をクリックしてからTaskA、TaskBが両方終わるまでbutton2への操作が効きません。
(厳密には効いているっぽいが実行が遅れる。UIスレッドの細部実装は今回の論点ではないので気にしない。)
このような課題の解決のために、TaskAとTaskBをワーカースレッドで非同期に実行するように変更してみます。
非同期処理の例と残った課題
以下が実装の変更部になります。
言語こそC#を使っているものの、この実装の思想はCにおけるpthread_create()のように超古来から使われてきたものと同じです。非同期処理の原点はこの実装スタイルだと考えてください。
private void button1_Click(object sender, EventArgs e) { System.Console.WriteLine("Normal Task1"); // 10秒かかる処理を開始する ←完了を待たない点に注意! Thread workerThreadA = new Thread(new ThreadStart(heavyTaskA)); workerThreadA.Start(); // 5秒かかる処理を開始する ←完了を待たない点に注意! Thread workerThreadB = new Thread(new ThreadStart(heavyTaskB)); workerThreadB.Start(); System.Console.WriteLine("Normal Task3"); }
この例だとTaskA、TaskBは一見並列に走っているように見え、button1をクリックした直後でもbutton2への操作が有効です。
次回:非同期処理と排他制御
今回はなにも考えずに実装したため図中の「重い処理の終了処理」あたりを実装できませんでした。このため、button1は「重い処理」が走ってる間でも押せてしまいます。 それで良い場合は良いのですが、「重い処理」が「REST APIへのPOST処理」のような場合には困ったことになると思います。
次回はこのあたりの制御方法(排他制御)から書きます。
2020/1/15 追記
書きました。