[JavaScript] スレッドの仕組みから、非同期処理を説明してみる

JavaScript

もしあなたがプログラミング言語を初めて習得しようとしてJavaScriptを選択しているのだとしたら、「非同期」というものについて、そもそも言葉の意味や処理のイメージなど、様々な部分で相当大きな壁を感じるのではないでしょうか。

私は今、JavaScriptのフルスタックエンジニアになるために勉強していますが、非同期処理について学習したときに「これは結構苦労している人がいるだろうな」と感じました。

このため、非同期処理について記事にしてみようと思ったのですが、非同期については他の様々なサイトで詳しく解説されています。
同じことを書いてもつまらないと思ったので、私は少し違う視点で非同期処理を説明する記事を書いてみようと思いました。

ブラウザを題材に「スレッド」というOSの仕組みを説明し、そこからJavaScriptにおける「非同期処理」というものについて説明していきたいと思います。

といっても、複雑な内容を語るのは避け、誤解を恐れず超絶端折って超簡単に解説してみます。

プロセスとスレッド

「プロセス」=プログラムとメモリ領域を管理する

WindowsやMacOS等のOS(オペレーティングシステム)はプログラムを実行するときに「プロセス」と呼ばれる特殊な領域を作成し、そこにプログラムを読み込んで実行を行います。ChromeやFirefoxなどのブラウザもOSから見ると1つのプログラムなので同じ仕組みで動きます。

OSは、プロセスを通じてプログラム領域とプログラムが利用するメモリ領域を管理します。
この仕組みにより、複数のプログラムを実行する場合でも、それぞれのプログラムは他のプロセスで動いているプログラムからの不正な干渉を受けることなく安全に実行することができます。

余談ですが、現在のWindowsやMacOSやLinuxは、マルチプロセス/マルチスレッドOSです。たとえコンピュータに1つのCPU/コアしかなくても、OSが疑似的にマルチプロセス/マルチスレッドを実行してくれます。このOSの機能により、1度に複数のプログラムが動いているように見えます。

昔のOS、例えばMS-DOSは、シングルプロセスOSでした。1つのプログラムが動いていると、ほかのプログラムを動かすことができませんでした。

「スレッド」=処理を実行する単位

プロセスには必ず「メインスレッド」が存在する

プロセスによってプログラムが読み込まれた後、そのままプログラムが実行されるわけではありません。マルチスレッドに対応しているOSの場合(WindowsやMacOSやLinuxのことです)、1つのプロセスには必ず1つのスレッド=メインスレッドが存在します。

プロセスに読み込まれたプログラムを実行するときには必ずメインスレッドが割り当てられて、メインスレッドの中でプログラムが実行されます。
プロセスは、メインスレッドのメモリ管理やプログラムの実行制御を行います。(このプロセスによる管理/制御がマルチスレッドで非常に重要な役割となります)

例えば、ブラウザを起動すると、ブラウザ用のプロセスが実行され、メインスレッドでブラウザ本体の処理が実行されます。

ここまでは、どのプログラムでも同じ仕組みのはずなので、概ね間違っていないと思いますが、私はブラウザのソースコードを理解しているわけではありませんので、ここからは私のスレッドに対する理解と、プロセスモニターを見て推測した内容に基づいて書いていきます。

「マルチスレッド」=複数コンテンツを処理する仕掛け

メインスレッドの処理は、あくまでもブラウザ全体の制御を行うための処理が実行されており、サイトへアクセスしてコンテンツを表示するという処理は含まれていないはずです。
では、それはどこで実行するのか。

昨今のブラウザは、1つのサイトだけではなく、タブ切り替えにより複数のサイトのコンテンツを同時に見ることができますよね。
その仕組みは、「マルチスレッド」という仕組みで実現していると思われます。
ブラウザでタブを追加すると、そのタブを処理するために、メインスレッドとは別のスレッドを追加し、そのスレッドで追加したタブの処理を行います。

例えば、ブラウザを起動すると、最初に1つのタブが表示され、デフォルトページが表示されますが、この最初のタブも、メインスレッドとは別のスレッドで実行されていると思われます。

スレッドの切り替え

使っているコンピューターのCPUが1つで、コアが2の場合、ハードウェア的に同時処理が可能なのは2つまでというのは簡単に理解できると思います。

このコンピューター上でブラウザを起動し、10個のタブで別々のサイトにアクセスする場合、処理するスレッドの数はメインスレッドを含めると11スレッドとなりますが、この条件下においても、OSはプロセスを通じて各スレッドが同時に動いているよう疑似的にマルチスレッドを実現します。

これはどう実現しているのでしょうか。

OSが疑似的にマルチスレッドを行う

各スレッドで実行する処理は、処理が完了する(タブが閉じられる)までループ処理を行っており、その処理を実行するために1つの物理的なコアを占有します。
2コアのコンピュータの場合、このままではメインスレッドと1つのタブのスレッド、合計2つの処理しか実行できません。
これを避けるために、スレッド内のループ処理では、一定のタイミングで自分の処理を停止し、別のスレッドにコアの利用権限を譲るという事を行います。

プロセスでは、実行中だったスレッドが停止したことを検知すると、待機している他のスレッドを実行するように制御します。

これが「スレッドの切り替え」です。

ここでは説明を省きましたが、本来、各スレッドには「優先順位」をつけることができます。スレッドの切り替えが発生すると、プロセスは優先順位も加味して次に制御を渡すスレッドを決定します。

この仕組みにより、より優先度の高いスレッドは、優先度の低いスレッドよりも頻繁に制御が渡されるようになります。

例えば、動画再生ソフトの場合、通常は映像の描画よりも音声再生が優先されますので、音声再生処理を行うスレッドの優先度が高いのが一般的です。
理由は、人間は映像描画時に遅延が発生したり画像が飛ぶことよりも、音声が遅延したり音声が飛ぶという事のほうにストレスを感じる傾向があるためという事のようです。

スレッドの再開には切り替え前の実行状態が必要

スレッドを切り替えるときに重要な事は、停止したスレッドが再開するときに、前回停止した場所/状態から再開できるようにすることです。

このことを実現するために、プロセスでは、各スレッドが停止した時のプログラムの位置(どこから、どの順番で呼び出されたか)、処理中の時に利用していた変数と値など、事細かい情報をメモリに退避します。

そして再開する際に、メモリに退避していた情報をもとに処理を再開するという事になります。

このスレッドの切り替え処理が「非同期処理」を理解する良いたとえになります。

JavaScriptにおける非同期処理

ここまで内容を理解したうえでJavaScriptの非同期処理を考えます。

JavaScript(特にブラウザ内で実行する場合)における非同期処理というのは、時間のかかる処理でCPU/コアが占有されることにより、画面処理ができずに画面がフリーズしたようになること(ユーザビリティの低下)を軽減するための仕組みだと考えることができます。

処理の流れとしては、非同期処理と非同期処理が完了した後に実行する処理を登録し、呼び出し元の処理は通常通り実行します。
登録された非同期処理はどこかのタイミングで実行が開始され、処理が完了すると、完了した後の処理を実行します。
その間、画面がフリーズすることはありません。

この処理の内容は、先ほどの「スレッドの切り替え」に酷似しているので、何となくイメージできたのではないでしょうか。

でも、先ほどの「タブ=スレッド」とあるように、ページ内で実行されるJavaScriptに与えられたスレッドは、実は1スレッドだけです。

例えば、fetchによるRest APIの呼び出しです。
fetchは非同期処理なので、APIを呼び出したら結果が返ってくる前に次の処理に進み、そのあと結果が返ってきたら所定の処理を実行するという動きになります。
マルチスレッドであればAPI呼び出し側とAPI側でスレッドを切り替えて処理を行うという説明ができますが、タブ内は1スレッド(シングルスレッド)しかありません。

それではJavaScriptの非同期処理はどのように実現しているのでしょうか。

JavaScriptの非同期処理は1スレッドで実現している

JavaScriptの非同期処理は、1つのスレッドで実行されていますが、裏の仕組みとしてスレッドの切り替えの考え方を流用することで疑似的に非同期処理を行っていると思われます。

この動きは簡単なJavaScriptで確認することができます。

検証1:スレッドの占有によるフリーズ現象

まずは単純なコードでタブのスレッドをフリーズさせてみましょう。
以下のコードを実行すると、10秒間タブの処理を占有することでフリーズしたように見えます。というか、実際にフリーズします。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function FreezeTab(freezeTime) { 
    alert("処理を開始(タブ内で何か実行してみてください)");
    const startTime = Date.now();
    while (true) {
        const diff = Date.now() - startTime;
        if (diff >= freezeTime) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
    alert("この行が呼ばれるまでタブの処理がフリーズする");
}
<html lang="ja">
    <head>
        <meta charset="utf-8" />
        <title>Freeze!</title>
    </head>
    <body>
        <a href="https://www.google.com/" target="_blank" rel="noopener noreferrer">ぐぐる</a>
        <button onclick="FreezeTab(10000);">Freeze!</button>
        <script src="TabFreeze.js"></script>
    </body>
</html>

同じフォルダーに上記2つのファイルを保存し、ブラウザでindex.htmlを実行します。
表示されたページ内に「Freeze!」ボタンが表示されるので、以下の点を確認しながらボタンをクリックしてJavaScriptを実行します。

1.実行前準備:いくつかタブを表示し、通常のサイト(なんでもOKです)を表示しておきます。
2.1回目の実行:実行中、タブ内の「ぐぐる」というリンクをクリックしてみてください。
3.2回目の実行:実行中、他のタブが操作できるか確認してみてください。

実行したタブ内がフリーズしましたね。
「ぐぐる」リンクが反応しなかったと思います。
タブの処理に割り当てられたスレッド処理がJavaScriptによって10秒間占有されました。これにより、他の処理、例えば画面描画やコントロールのイベントなど、タブの中で実行すべき処理が実行できない状態になりました。

そして2回目の実行の時、他のタブは問題なく動作していたと思います。
これがまさにマルチスレッドの為せる業です。Processの各スレッドの制御により、1つのスレッドがCPU/コアを優先することなく、他のスレッド(タブ)が実行されていました。

この検証で、タブが1つのスレッドで動作しているという事がわかりました。
そして1つのスレッドを1つの処理で占有すると、そのスレッド内で実行しなければならない他の処理が実行できなくなることがわかりました。

でも、通常サイト内のデータは、イベントに応じてJavaScriptが実行されますが、その時も特に問題なく操作できます。

そろそろJavaScriptにおける非同期処理の核心に迫っていきます。

検証2:非同期処理によるフリーズの回避

非同期処理が必要となる理由がまさにこれです。
JavaScriptでは、外部サービスとの通信や、ファイルの読み込み(Node.js)などの時間がかかる処理については非同期で処理されることが前提となっていますが、これはブラウザで非常に優先度の高いユーザーインタフェースの処理を止めないための仕組みです。

時間のかかる処理が1つのスレッド処理を占有してしまうと、1つ目の検証の時に発生した「ブラウザがフリーズしたようになる」のです。
これではユーザビリティが下がることになるので、非同期処理によりこの現象を回避するのです。

早速検証していきましょう。

async function sleep(msec) {
  const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
  console.log('スタート');
  await sleep(msec);
  console.log('10秒経過!')
}
<html lang="ja">
    <head>
        <meta charset="utf-8" />
        <title>Sleep...</title>
    </head>
    <body>
        <a href="https://www.google.com/" target="_blank" rel="noopener noreferrer">ぐぐる</a>
        <button onclick="sleep(10000);">Sleep...</button>
        <script src="sleep.js"></script>
    </body>
</html>

上記コードはコンソールログに文字列を出力しているので、ChromeであればF12を押して開発者ツールを起動し、コンソールを確認しながら実行してみてください。

上記はPromiseオブジェクトとAsync / awaitを組み合わせた非同期処理を実現するためのプログラムコードになっています。このあたりのテクニックは、様々なサイトで紹介されているのでここでは割愛しています。

実行すると、コンソールログに「スタート」という文字が出力され、その後10秒間は何もしません。
ですが、タブ内に表示された画面は操作できます。
試しに「ぐぐる」をクリックしてみてください。
Googleの検索トップ画面が表示されます。

何度も「Sleep…」をクリックしてみてください。
何度も「スタート」文字がコンソールに出力され、10秒経過したころから、順次ボタンをクリックした回数分「10秒経過!」という文字がコンソールに出力されます。

このように、JavaScriptでは基本的に1スレッドで動作するようになっていますが、非同期処理をうまく使うことで、異なる処理を同時に実行しているように見せることができます。

ただし、OSが処理してくれるマルチスレッド処理はあくまでスレッド単位なので、この非同期処理はブラウザのJavaScriptエンジンが1スレッド内で行っている「疑似的なスレッド切り替え」のような処理に過ぎないという事は理解しておく必要はあります。

理由は単純で、あくまでも1スレッド内での疑似的な切り替え処理であり、どこかでスレッドを占有されると、切り替え処理すらも実行されなくなる(フリーズが発生する)という事になるからです。

検証3:スレッドを占有すると非同期処理も実行されない

先ほどの「疑似的なスレッド切り替え」のような処理であるため、スレッドを占有する処理があると結局フリーズすると書きましたが、それを検証してみましょう。
非同期処理の実行とスレッド占有となる処理を組み合わせてみて、ブラウザの挙動がどうなるか確認してみます。

以下の処理は、実行後10ミリ秒後にコールバック関数を実行するよう非同期処理を実装していますが、非同期処理設定後すぐに5秒間フリーズさせる処理を入れています。

このような、スレッド占有となる処理がある場合、非同期処理がどのように処理されるでしょうか。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function blockTime(timeout) {
    const startTime = Date.now();
    while (true) {
        const diffTime = Date.now() - startTime;
        if (diffTime >= timeout) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
}

function main() {
  const startTime = Date.now();
  // 10ミリ秒後にコールバック関数を呼び出すようにタイマーに登録する
  setTimeout(() => {
      const endTime = Date.now();
      console.log(`非同期処理のコールバックが呼ばれるまで${endTime - startTime}ミリ秒かかりました`);
  }, 10);
  console.log("ブロックする処理を開始します");
  blockTime(5000); // 5秒間処理をブロックする
  console.log("ブロックする処理が完了しました");
}
<html lang="ja">
    <head>
        <meta charset="utf-8" />
        <title>Async test</title>
    </head>
    <body>
        <button onclick="main();">Do it</button>
        <script src="index.js"></script>
    </body>
</html>

先ほどと同じように、開発者ツールのコンソールを確認しながら実行します。
画面に「Do it」というボタンが表示されるので、クリックしてコンソールを確認しましょう。

もし複数スレッドで処理されている場合、非同期処理は10ミリ秒後に実行されるはずなので、コンソールには以下のように出力されるはずです。

ブロックする処理を開始します
非同期処理のコールバックが呼ばれるまで10ミリ秒かかりました。
ブロック処理が完了しました

ですが、実際には以下のように出力されるはずです。
※想定では5010ミリ秒ですが、実際には誤差が発生します。

ブロックする処理を開始します
非同期処理のコールバックが呼ばれるまで5010ミリ秒かかりました。
ブロック処理が完了しました。

この処理結果から、非同期処理を実行した後に行っている5秒のブロック時間の後に、ようやくコールバック関数が呼び出されていることがわかります。

これまでの検証結果から、少なくとも1つのタブで実行されるJavaScriptは1スレッドで実行されており、非同期処理であろうとも、その影響を受けるという事がわかります。

それではまとめていきます。

まとめ

最近のコンピューターはマルチプロセス/マルチスレッドで処理を行います。
1つのプログラムが起動するとき、プロセス領域にプログラムが読み込まれ、スレッドが割り当てられて実行されます。
ブラウザも同様で、メインスレッドはブラウザの制御系処理を行い、各タブでサイトコンテンツを処理するためにスレッドが割り当てられると思われます。
通常、スレッドはプロセス管理のもとで切り替えながら処理されます。

今回の検証により、ブラウザのJavaScriptは、1つのスレッド(シングルスレッド)で処理されているという事がわかりました。
スレッドでは1つの処理が占有すると他の処理(ブラウザでは特にUI処理)が実行できなくなるため、非同期というやり方でユーザビリティの劣化を緩和している事がわかりました。
「緩和」という表現を使ったのは、私たちエンジニアがしっかり仕組みを理解せずにプログラムした場合、非同期処理であってもユーザービリティは劣化する可能性があるという事を含んでいるためです。

私たちは、常にユーザビリティを考慮しながらプログラムを構築する必要があります。
JavaScriptにおいて、非同期処理はそれを実現するために超絶重要な仕組みだったという事ですね。

コメント

タイトルとURLをコピーしました