7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Siv3DAdvent Calendar 2022

Day 12

C++20のコルーチンを使ってSiv3D for Webでも非同期処理

Last updated at Posted at 2022-12-11

Siv3D for Web でも非同期処理がしたかったので、 C++20 で追加されたコルーチンを使ってシングルスレッドで動く非同期処理ライブラリ CoroAsync を作りました。
このライブラリは Siv3D for Web 以外の環境でも使うことができます。

Siv3D for Web でも非同期処理したい

皆さんは Siv3D for Web (OpenSiv3D for Web) を使っていますか?
Siv3D for Web はブラウザで動く Web アプリケーションを C++20 を使って作れるライブラリです。

Siv3D の機能をほぼそのまま使えるため、 Siv3D 製アプリの Web 版の作成が非常に簡単にできます。
Web 版を公開すれば、ユーザのインストールの手間がなくなったり、スマホからでもアクセスできるようになったりするため、より多くの人に作ったアプリを触ってもらえるでしょう。

しかし、 Siv3D for Web では使えない機能がいくつかあります1
その1つがマルチスレッドです。
Siv3D for Web はシングルスレッドで動作する設計であるため、スレッドによる非同期処理ができません。

Siv3D for Web でスレッドを利用しているアプリの Web 版を作るには、スレッドを使わない形に書き換える必要があります。
しかし、非同期処理を諦めると処理中にアプリがフリーズするようなことになりかねません。
どうにかして、スレッドを使わずに非同期処理がしたいです。

ところで、 JavaScript はシングルスレッドで動作しますが、 async/await によって非同期処理が可能です。
C++ で 同じようなことができないでしょうか?

C++20 のコルーチン

できます。そう、 C++20 なら!

C++20 ではコルーチンが導入されました。
コルーチンは関数と似ていますが、処理の途中で中断し、途中から再開することができます。
これにより、複数の処理を、少しずつ、入れ替えながら実行することで非同期処理を実現できます。

しかし、 C++20 で追加されたのはコルーチンに関する言語仕様と構文、低レベルライブラリだけで、非同期タスクやジェネレータといったライブラリはまだ導入されていません。

CoroAsync

ないなら作るしかねぇよな!

というわけで、コルーチンで非同期処理をするためのライブラリ、 CoroAsync を作りました!
ヘッダオンリーなライブラリなので、ダウンロードしてインクルードするだけで使えます。

簡単な使用例
# include <iostream>
# include "CoroAsync/Task.hpp"

// タスクの定義
cra::Task<> CountDown(int id)
{
    for (int i = 5; i > 0; --i)
    {
        std::cout << "task(" << id << "): " << i << '\n';
        co_await 0;  // 中断ポイント
    }
}

int main()
{
    auto task1 = CountDown(1);
    auto task2 = CountDown(2);

    task1.wait();
    task2.wait();
}
出力
task(1): 5
task(2): 5
task(1): 4
task(2): 4
task(1): 3
task(2): 3
task(1): 2
task(2): 2
task(1): 1
task(2): 1

使い方に関しては、サンプルやヘッダファイルのコメントを読んでください。

GitHub: https://github.com/Raclamusi/CoroAsync
サンプル (GitHub): https://github.com/Raclamusi/CoroAsync/blob/main/Sample.cpp?ts=4
サンプル (Wandbox): https://wandbox.org/permlink/qEg1Vyl1TiD5Xix6

サンプルを Qiita で直接見たい場合はこちら
Sample.cpp
# include <iostream>
# include <chrono>
# include <type_traits>
# include "CoroAsync/Task.hpp"
# include "CoroAsync/Utility.hpp"

using namespace std::literals::chrono_literals;

// 非同期処理するタスクの定義
//     cra::Task<Type> を戻り値の型に指定し、 co_await もしくは co_return を少なくとも1回使用する。
//     cra::Task<void> は cra::Task<> とも書ける。
cra::Task<> FuncAsync(int id)
{
	std::cout << "FuncAsync(" << id << "): begin\n";

	// co_await で中断ポイントを指定
	//     co_await relTime
	//         タスクを中断し、 relTime の時間経過を待ってからタスクキューの末尾に追加する。
	//         relTime の型は std::uint32_t もしくは std::chrono::duration<Rep, Period> であり、
	//         std::uint32_t の場合は co_await std::chrono::milliseconds{ relTime } と同じ効果を持つ。
	//         relTime の値が 0 以下の時、タスクは何も待たずにタスクキューの末尾に追加される。
	//         void に評価される。
	co_await 0;

	for (int i = 1; i <= 3; ++i)
	{
		std::cout << "FuncAsync(" << id << "): " << i << '\n';
		co_await 100ms;
	}
	std::cout << "FuncAsync(" << id << "): end\n";
}

// 返り値のあるタスクの定義
cra::Task<unsigned int> FibonacciAsync(unsigned int n)
{
	if (n <= 1)
	{
		// co_return でタスクの結果を返して終了
		co_return n;
	}

	// co_await で中断ポイントを指定
	//     co_await task
	//         タスクを中断し、指定したタスクの完了を待ってからタスクキューの末尾に追加する。
	//         task の型は cra::Task<Type> である。
	//         Type に評価される。
	auto ret = co_await FibonacciAsync(n - 1) + co_await FibonacciAsync(n - 2);

	co_return ret;
}

int main()
{
	{
		// タスクの作成
		//     戻り値は必ず受け取るようにする。
		auto task1 = FuncAsync(1);
		auto task2 = FuncAsync(2);
		auto task3 = FuncAsync(3);

		// タスクを待機
		// タイムアウトを指定して待機
		std::cout << "main: wait 150ms for task1\n";
		task1.wait_for(150ms);
		// タスクの完了まで待機
		std::cout << "main: wait for task2\n";
		task2.wait();

		// タスクの完了を確認
		std::cout << "main: task1 is " << (task1.isReady() ? "ready" : "not ready") << "\n";
		std::cout << "main: task2 is " << (task2.isReady() ? "ready" : "not ready") << "\n";
		std::cout << "main: task3 is " << (task3.isReady() ? "ready" : "not ready") << "\n";

		// タスクを中断
		//     以下の場合、タスクは中断され、それ以降は実行されない。
		//       - cra::Task::destroy を呼び出した場合
		//       - 別のタスクや空のタスクが代入された場合
		//       - デストラクタが呼び出された場合
		// t3.destroy();
		// t3 = {};
	}
	std::cout << "----------\n";
	{
		auto fib5 = FibonacciAsync(5);
		auto fib10 = FibonacciAsync(10);

		// タスクの結果を取得
		//     タスクが完了していない場合、完了まで待機する。
		std::cout << "main: fib5 = " << fib5.get() << "\n";
		std::cout << "main: fib10 = " << fib10.get() << "\n";

		// タスクを生成して結果を即取得
		std::cout << "main: fib15 = " << FibonacciAsync(15).get() << "\n";
	}
	std::cout << "----------\n";
	{
		// ラムダ式によるタスクの定義
		auto getPiAsync = []() -> cra::Task<double>
		{
			co_return 3.14159;
		};

		auto piTask = getPiAsync();
		auto pi = piTask.get();
		std::cout << "main: pi = " << pi << "\n";

		// タスクの定義と作成、待機を同時にすることも可能
		auto e = []() -> cra::Task<double> { co_return 2.71828; }().get();
		std::cout << "main: e = " << e << "\n";
	}
	std::cout << "----------\n";
	{
		auto t1 = []() -> cra::Task<std::string> { co_return "Hello"; }();
		auto t2 = []() -> cra::Task<std::string> { co_return "Good-bye"; }();

		// すべてのタスクの完了を待つタスクの作成
		//     cra::WhenAll は渡したタスクがすべて完了するのを待つタスクを返す。
		//     タスクは値で受け取るため、その場で作成するか、ムーブする必要がある。
		//     結果は std::tuple で受け取れる。このとき、結果の型が void であるものは除かれる。
		//     例えば、 WhenAll(Task<int>, Task<>, Task<std::string>) の戻り値の型は Task<std::tuple<int, std::string>> である。
		auto whenAllTask = cra::WhenAll(
			[]() -> cra::Task<int> { co_return 42; }(),
			FuncAsync(4),
			std::move(t1)
		);
		auto [a, b] = whenAllTask.get();
		std::cout << "main: WhenAll result: { " << a << ", " << b << " }\n";

		// いずれかのタスクの完了を待つタスクの作成
		//     cra::WhenAny は渡したタスクがいずれかが完了するのを待つタスクを返す。
		//     タスクを値で受け取るものと、非 const 参照で受け取るものがオーバーロードで用意されている。
		//     最初に完了したタスクに対して処理を行いつつ、最終的にはすべてのタスクの完了を待ちたい場合などに、後者を用いることができる。
		//     結果は最初に完了したタスク(参照で渡した場合は参照)が std::variant で受け取れる。
		//     std::variant::index() で何番目のタスクが最初に完了したかを知ることができる。
		auto whenAnyTask = cra::WhenAny(
			[]() -> cra::Task<> { co_await 24h; }(),
			FuncAsync(5),
			std::move(t2)
		);
		auto whenAnyResult = whenAnyTask.get();
		std::cout << "main: t" << whenAnyResult.index() << " was done first in WhenAny(t0, t1, t2)\n";
		std::visit([]<class T>(cra::Task<T>& task) {
			std::cout << "main: WhenAny result: ";
			if constexpr (std::is_void_v<T>) std::cout << "(void)";
			else std::cout << task.get();
			std::cout << "\n";
		}, whenAnyResult);
	}
}

Siv3D for Web で非同期処理

ライブラリができたので、実際に Siv3D for Web で非同期処理をしてみます。

Siv3D for Web の System::Update() がコルーチンの中で呼ばれると unreachable エラーが発生する問題があるので、 Siv3D 向けサンプルのような書き方は Siv3D for Web ではできないことに注意して実装します(おそらく https://github.com/nokotan/OpenSiv3D/issues/11 と同じような問題)。

完成したものがこちらです。

ソース: https://github.com/Raclamusi/CoroAsync/blob/main/SampleForSiv3DForWeb.cpp?ts=4

カーソルの追従、カウントアップ、バーの表示が非同期で実行されています!

CaptureOfSampleForSiv3DForWeb.gif

GCC のバグに苦しまされた話

GCC のコルーチンには co_await に渡すオブジェクトをコピーするバグがあるようで、そのままではコピー不可な cra::Task のオブジェクトが co_await に渡せませんでした2
CoroAsync では、ラッパクラスを作ってコピーを回避しています。

ほかにも「 Visual Studio では動くけど GCC では動かない」といったことがいくつかあり、その対応がかなり大変でした。
マジでこれどうにかしてくれ。

参考サイト

  1. https://siv3d.kamenokosoft.com/ja/trouble-shooting/web-specific-notes

  2. https://twitter.com/onihusube9/status/1595359093417725952

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?