Node.jsの非同期 I/Oについて調べてみた

どのようにしてシングルスレッドで複数の処理を捌くか

Node.jsの非同期I/Oについて調べてみた

こんにちは、本記事は リクルートライフスタイル Advent Calendar 2019 13 日目の記事です。今日は sadnessOjisan がやっていきます。この記事では Node.js の非同期 I/O について調べたことを紹介します。

調べようと思ったきっかけは、先日の JSConfJP で Wrap-up: Runtime-friendly JavaScriptというランタイムレベルでの最適化を解説したセッションを見て Node.js の理解を深めたいと思ったからです。私は Node.js でのコーディングは多少経験がある程度なので、まずは Node.js の大きな特徴である非同期 I/O からキャッチアップすることにしました。

Node.js の大きな特徴

Node.js は公式の説明を借りると、スケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動の JavaScript 環境です。その大きな特徴としては、

  • シングルスレッドで動作すること
  • I/O が非同期であること

があげられます。

シングルスレッドで動作するとは

Node.js はシングルスレッドで動作します。一つのスレッドしか使えないということは、一度に一つの処理しか実行できず非効率そうにも見えます。なぜシングルスレッドで動くように設計されたのでしょうか。

スレッドとは

スレッドは CPU 利用の単位です。CPU 利用の単位としては、プロセスとスレッドがあります。関係でいうと、実行中のプログラムをプロセスと呼び、プロセスは一つ以上のスレッドを持ちます。

複数のスレッドで動くとは

サーバーは複数リクエストをあつかうときに、利用するプロセスやスレッドを増やすことで、それぞれのリクエストが要求する処理を同時に行うことができます。

マルチスレッドでリクエストを捌く

しかし、あまりにも多くのリクエストが届くと、その分だけプロセス/スレッドが作成されて、メモリの消費量が多くなったり、コンテキストスイッチによって、パフォーマンスが落ちる可能性があります。これは C10K 問題として知られている問題です。

Node.js が注目された理由

Node.js はこの C10K 問題を解決できるものとして注目されていました。Node.js はマルチスレッドで実行するのではなく、シングルスレッドで実行するため、C10K 問題を解決することができます。しかし、シングルスレッドで動かすことは、処理の同時実行を考えると非効率な実行方法にも見えます。そこで Node.js は処理をスレッドの数に分散させるのではなく、時間軸で分散することで同時実行できない問題の解決を図っています。

シングルスレッドで動かす鍵は 非同期 I/O

時間軸による処理の分散を可能にするために Node.js では非同期 I/O を利用します。

I/O とは

アプリケーション外部との入出力処理を I/O といいます。例えばファイルからデータを読み取る処理や、Socket からデータを読み込む処理を指します。

I/Oとは

一般的にファイル I/O やネットワーク I/O は Memory I/O に比べると遅く、またデータを外部から読み取る以上は読み取り結果を取得するための待ち時間も発生したりするため、I/O が発生するとその処理は遅くなります。

さらにその処理をシングルスレッドで動作させると、後続の処理も遅れます。特にサーバーにおいてはある人のリクエストによって I/O が発生すると、後からリクエストした他の人の処理が遅れるため、パフォーマンス上の問題にもなりやすいです。そのため Node.js では I/O による処理のブロックを避けるデザインがされています。

非同期 I/O

私は Node.js を初めて書いたとき、このようなコードを書いて、データを読み取れなかった経験があります。

1
2
3
4
5
var input;
fs.readFile("/sample.txt", "utf8", function(err, data) {
  input = data;
});
console.log(input);

これは、data を読み取って input に代入する前に console.log(input) が実行されているから起きる現象です。つまり、I/O の完了を待たずに console.log(input) が実行されています。I/O による待ち時間は発生しておらず、後続処理をブロックしていません。このように Node.js では後続処理をブロッキングしない非同期 I/O として利用できます。

処理の分散

Node.js はこの非同期 I/O を駆使して、時間軸で処理を分散することができます。

Reactor Pattern による I/O 対応

処理を時間軸で分散させるために、Node.js では I/O の完了を待たずに後続処理を実行させます。そして、I/O の結果に紐づいた処理の実行は、I/O が完了したかどうかを知り、完了していたらデータを取り出して処理を実行するという方式で実行されます。これを実現するためには I/O の完了ステータスをアプリケーションが知る必要があります。

I/O の完了を監視する

では、どのようにして I/O が完了したかのステータスを知るのでしょうか。単純に考えると、I/O の完了を監視すれば良さそうです。ファイルを読み取ることを考えましょう。ファイルの中身を出力するためには次のステップを踏みます。

  1. ファイルへの read を要求する
  2. ファイルが read 可能になる
  3. ファイルの中身を読み取る

単純に考えると、この処理を実現するためには無限ループを用いて I/O の完了ステータスを監視し、処理が可能になることを待つ方法が考えられます。

処理の分散

しかし I/O が完了したかを判定する処理をループでずっと実行することは、計算資源を非効率に使っているため推奨される方法ではありません。これは Busy Waiting とも呼ばれ避けるべき実装方法です。

Reactor Pattern

そこで、Node.js では Reactor Pattern と呼ばれる方法で、複数の I/O を処理します。このパターンでは I/O が完了したかを監視するために、OS から提供されている監視機能を使います。

イベント多重分離

  1. I/O が要求される、そのときに完了時の処理も受け取る
  2. I/O が完了していなくても、アプリケーション側に処理を戻す
  3. I/O が完了すると、完了時にすべき処理を queue に入れる
  4. event loop がその queue から処理を取り出し実行する

I/O の完了はどう判定しているのか

上の Reactor Pattern では、 I/O Event Demultiplexing(イベント多重分離)というテクニックを使って、処理を非同期に実行しています。

Event Demultiplexing はどのようにして実現するのか

Event Demultiplexer は無駄な while ループを作らなくても I/O の結果を監視できる機構です。しかしこの Event Demultiplexer は説明で使われる概念的なものであり、その実体は OS が用意しているイベント通知インターフェースを指します。例えばファイル I/O を監視する場合、MacOS なら kqueue を利用できます。

kqueue を使えば、例えばファイルへの書き込みはこのようにして監視できます。

1
2
EV_SET(&kev, fd, EVFILT_VNODE, EV_ADD, NOTE_WRITE, 0, NULL);
ret = kevent(kq, &kev, 1, NULL, 0, NULL);

(https://github.com/sadnessOjisan/kqueue_fileio)

このように、「I/O が完了したときになになにする」といった監視やハンドラの実行は、OS の機能を使うことで実現できます。OS からイベントの完了通知を受け取るため、I/O 完了の判定のための while ループを用意する必要もありません。

OS の機能を呼べば Reactor Pattern を実装できるのか

kqueue を使うことで I/O の監視ができることが確認できました。そのためあとはイベントループを用意すれば、 Reactor Pattern を実現できそうです。しかし、この kqueue は BSD 系の OS(Mac 含む) 以外にはなく、kqueue はどのプラットフォームでも使えるものではありません。そこで Node.js ではこのようなイベント通知の仕組みを色々なプラットフォームで利用できるようにしてくれているライブラリ libuv を利用します。

libuv

libuvは公式の説明に、'libuv is a multi-platform support library with a focus on asynchronous I/O.‘ とあり、マルチプラットフォームでの非同期 I/O 提供するライブラリです。しかし、非同期 I/O を抽象化しただけでなく、イベントキューやイベントループも提供しているため、Reactor Pattern をさまざまなプラットフォームで実現できるようにしたライブラリとも言えるでしょう。

libuv はどのようにイベントループを提供しているのか

libuv にはuvbookというとても丁寧な libuv のユーザーガイドがあります。これを参考にしてみましょう。

libuv は非同期 I/O だけでなく、それ自体がイベントループを提供しています。このようにすればイベントループを実行できます。

1
uv_run(uv_default_loop(), UV_RUN_DEFAULT);

それでは I/O を監視させてみましょう。libuv では、このループの中で「こういう I/O 要求があったときには、こうしてほしい」という watcher を登録できます。

1
uv_fs_read(uv_default_loop(), &readReq, req->result, &uvBuf, 1, -1, onRead);

ここではファイル書き込みがあったときに、onRead 関数を実行するように登録しました。コールバック関数を登録するような感じでなんとなく Node.js の雰囲気を感じます。

(https://github.com/sadnessOjisan/uv_read)

まとめ

  • Node.js の特徴はシングルスレッドでの動作と非同期 I/O
  • シングルスレッドでも複数のリクエストを捌くアイデアとして イベント駆動形式を採用している
  • Reactor Pattern は OS の機能で実現されるが、それをどの OS でも Reactor Pattern を実現するために libuv を利用している

参考文献

井手 優太

(ホットペッパービューティー開発チーム)

あだ名が統合開発環境

Tags

Bubbles

Android 10で追加された新機能

Android 10で追加されたBubblesについて

本記事はリクルートライフスタイル Advent Calendar 2019の12日目の記事です。

ホットペッパービューティーのネイティブアプリ開発を担当している中里です。この記事では、Android 10で追加されたBubblesの使い方と、使ってみた感想を書きたいと思います。

Bubblesとは?

Bubblesとは、Facebook Messengerのように他のアプリの上に描画する機能で、日本語だと「ふきだし」と訳されています。
https://developer.android.com/guide/topics/ui/bubbles

従来このようなUIを実現するためには、 SYSTEM_ALERT_WINDOW という権限が必要でしたが、 SYSTEM_ALERT_WINDOW は強い権限であり、また電池消費の観点からも不利であるため、それを代替するためにAndroid 10から登場しました。

アプリの作成

プロジェクトの作成

まず、いつも通りにプロジェクトを作成します。当然ですが、APIレベルは29以降に設定する必要があります。

1
2
3
4
5
6
7
android {
    compileSdkVersion 29
    defaultConfig {
        minSdkVersion 29
        targetSdkVersion 29
    }
}

Bubble用のActivityを作成

Bubbleの中身として表示したい内容を、Activityとして作成します。

1
2
3
4
5
6
class BubbleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_bubble)
    }
}

次に、作成したActivityをAndroidManifestに追加します。

1
2
3
4
5
<activity
    android:name=".BubbleActivity"
    android:allowEmbedded="true"
    android:documentLaunchMode="always"
    android:resizeableActivity="true" />

Bubbleとして表示するためには、以下の3つの値を設定する必要があります。

  • allowEmbedded
    • Activityを別のActivityの子として起動できるかどうか。
  • documentLaunchMode
    • Activity起動時のタスクの追加方法。always を指定すると、常に新しいタスクを作成する。
  • resizeableActivity
    • 画面分割のように、サイズ変更が可能なActivityかどうか。

Bubbleの呼び出し

Bubbleの呼び出しは、以下のように基本的には通知と同じような実装になります。

  1. 通知を送るチャンネルの作成
  2. 通知の送信
    1. PendingIntentの作成
    2. BubbleMetadataの作成
    3. Notificationの作成
    4. NotificationManagerによる通知

まず、通知を送るチャンネルを作成します。このとき、Bubblesとして表示するために setAllowBubbles(true) を設定します。

1
2
3
4
5
6
7
private fun createNotificationChannel() {
    val channel = NotificationChannel("Channel Id", "Channel Name", NotificationManager.IMPORTANCE_HIGH)
    channel.description = "Channel Description"
    channel.setAllowBubbles(true)
    val notificationManager: NotificationManager = getSystemService() ?: return
    notificationManager.createNotificationChannel(channel)
}

次に、実際に通知を送信する処理です。

そのためにまずは、PendingIntentを作成します。

1
2
val intent = Intent(this, BubbleActivity::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, 0)

次に、BubbleMetadataを作成します。ちなみにここで setAutoExpandBubble(false) にすると、Bubbleが閉じた状態で表示されます。

1
2
3
4
5
6
val bubbleMetadata = Notification.BubbleMetadata.Builder()
    .setDesiredHeight(600)
    .setIcon(Icon.createWithResource(this, R.drawable.ic_launcher_foreground))
    .setIntent(pendingIntent)
    .setAutoExpandBubble(true)
    .build()

最後に、Notificationを作成します。ドキュメントだとPersonを指定していますが、指定しなくてもBubbleの表示には特に関係ありませんでした。

1
2
3
4
val notification = Notification.Builder(this, "Channel Id")
    .setSmallIcon(R.drawable.ic_launcher_foreground)
    .setBubbleMetadata(bubbleMetadata)
    .build()

あとは、作成したNotificationをNotificationManagerに投げます。

1
2
val notificationManager = NotificationManagerCompat.from(this)
notificationManager.notify(0, notification)

ここまで、実装したらあとは実行!と行きたいところですが、このままだと普通の通知として表示されてしまいます。

Notification

Bubblesの設定

Bubblesは公式ドキュメントにも書いてある通り、デベロッパープレビューであり、デフォルトでは無効になっています。Bubblesを利用するためには、開発者向けオプションから有効にする必要があります。

Settings

あるいは、以下のadbコマンドを実行しても有効にすることができます。

1
adb shell settings put secure notification_bubbles 1

実行結果

以上の実装・設定をすると、以下のように表示することができます。

Bubble

戻るボタンや暗い部分をタップすることで、小さいアイコンだけの表示になります。

Small bubble

このアイコンは動かすことができ、下の方に持っていけばdismissすることができます。

Dismiss

また、通知IDを変えて複数回呼び出せば、複数表示することもできます。

Multi

別の画面への遷移

Bubbleとして表示しているActivityから startActivity で別のActivityに遷移することもできます。そのためには、遷移先のActivityも同様にAndroidManifestに定義する必要があります。ただし、画面遷移の場合は新しいタスクを作成する必要はないので、 documentLaunchMode は指定しません。

1
2
3
4
<activity
    android:name=".SecondActivity"
    android:allowEmbedded="true"
    android:resizeableActivity="true" />

Second

戻るボタンを押すことで、前のActivityに戻ることもできます。

ちなみにAndroidManifestを正しく書かないと、Bubbleは閉じて普通に全画面遷移してしまいます。また、このあと再びBubbleを開くと、以下のような真っ黒な表示になってしまいました😅

Second error

ライフサイクル

Bubbleとして表示しているActivityのライフサイクルについては、表示時は

1
onCreate → onStart → onResume

と走り、Bubbleを閉じて小さいアイコンの状態に変わるときは、

1
onPause → onStop

となり、再び表示する時はまたonStart(onRestart)から始まりました。また、dismissしたらonDestroyが走りました。ライフサイクルについては特に意外性はなさそうです。

まとめ

実装方法は通常の通知と大きく変わらないため、そこまで新しい知識は必要とせずに実装することができました。ただ、古いユーザー向けには通知として表示されてしまうので、使い方は工夫する必要がありそうです。個人的には、チャットなどのアプリ以外でもBubblesは便利そうだなと感じたので、早く正式版になって欲しいなーと思いました。

それでは良いお年を🎄

中里 直人

(ビューティー事業ユニット プロダクト開発グループ)

ビール(IPA)が好きです

Tags

NEXT