みなさん、C# の EventWaitHandle や Python の threading.Event (以降、EventWaitHandle で統一) をご存知でしょうか。
非同期処理との同期をとるのにとても重宝するクラスです。
しかし、TypeScript では EventWaitHandle みたいなクラスはあまり見かけません。npm パッケージとして公開されていないかも探してみましたが見つかりませんでした。
TypeScript は Promise オブジェクトと async/await 構文を使って非常に効率的に非同期プログラミングが行えます。
これらを駆使することで、EventWaitHandle が無くとも、ほぼ同等のことも実現できてしまいます。
Promise
例えば次のようなサードパーティ製 (=変更不可) のクラスがあったとします。
class Hoge {
public constructor() {
this._eventEmitter = new EventEmitter();
// 非同期で 1 秒経過後に処理を完了し通知。
setTimeout(() => {
console.log("Prepared.");
this._eventEmitter.emit("prepared");
}, 1000);
}
private readonly _eventEmitter: EventEmitter;
public on(event: "prepared", listener: () => void) {
this._eventEmitter.on("prepared", listener);
}
}
この Hoge クラスでは、コンストラクタが非同期に初期化を行い、"prepared" という event で初期化完了を通知しています。Promise や async/await は使っていません。
Hoge オブジェクトを生成し、初期化完了後に何か処理を行いたい場合、単純な処理であれば次のように直接 "prepared" の listener として記述すれば十分です。
const hoge = new Hoge();
hoge.on("prepared", () => {
console.log("The hoge is ready.");
});
これを、非同期処理との同期が行えるよう async 関数にしたい場合は、次のように Promise オブジェクトを使用します。
public async foo() {
await new Promise((resolve) => {
const hoge = new Hoge();
hoge.on("prepared", () => {
this._hoge = hoge;
console.log("The hoge is ready.");
resolve();
});
});
}
この位であればまだ大したことはありません。
しかし、例えば Hoge オブジェクトの他に、似たような形式の Fuga オブジェクトも作る必要があるといった場合、ネストが深くなっていきます。
public async foo() {
await new Promise((resolve) => {
const hoge = new Hoge();
hoge1.on("prepared", () => {
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
fuga.on("prepared", () => {
this._fuga = fuga;
console.log("The fuga is ready.");
resolve();
});
});
});
}
段々しんどくなってきましたね。
ただ、これは次のように書き換えることでネストを回避しシンプルに記述することができます。
public async foo() {
const hoge = new Hoge();
await new Promise((resolve) => {
hoge.on("prepared", resolve);
});
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
await new Promise((resolve) => {
fuga.on("prepared", resolve);
});
this._fuga = fuga;
console.log("The fuga is ready.");
}
もう一押し。
public async foo() {
const hoge = new Hoge();
await new Promise(resolve => hoge.on("prepared", resolve));
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
await new Promise(resolve => fuga.on("prepared", resolve));
this._fuga = fuga;
console.log("The fuga is ready.");
}
うん、大分すっきりしました。
Promise + setTimeout
では次に、prepared が呼ばれない場合1に備えて 5 秒のタイムアウトを設けるという場合を見てみましょう。
public async foo() {
const hoge = new Hoge();
await new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("Timeout.")), 5000);
hoge.on("prepared", resolve);
});
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
await new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("Timeout.")), 5000);
fuga.on("prepared", resolve);
});
this._fuga = fuga;
console.log("The fuga is ready.");
}
Promise の executer 内に、5 秒後に reject するよう setTimeout でハンドラを仕込んでいます。これにより、5 秒以内に resolve されなければ呼び出し元にエラーが通知されます。
5 秒以内に resolve された場合でもこのハンドラ自体は呼ばれますが、既に完了している Promise に対する resolve や reject は無視されますので、他に処理を行わなければ問題ありません。
Attempting to resolve or reject a resolved promise has no effect.
(https://www.ecma-international.org/ecma-262/#sec-promise-objects)
エラーにしたくない場合は、reject ではなく resolve を使い、boolean 値を返すのがベターです。
public async foo() {
const hoge = new Hoge();
const hogeIsReady = await new Promise((resolve) => {
setTimeout(() => resolve(false), 5000);
hoge.on("prepared", () => resolve(true));
});
this._hoge = hoge;
console.log(`The hoge is ${hogeIsReady ? "ready" : "not ready"}.`);
const fuga = new Fuga();
const fugaIsReady = await new Promise((resolve) => {
setTimeout(() => resolve(false), 5000);
fuga.on("prepared", () => resolve(true));
});
this._fuga = fuga;
console.log(`The fuga is ${fugaIsReady ? "ready" : "not ready"}.`);
}
EventWaitHandle
さて、ようやくタイトルのお話です。
ここまでに見てきたコードは、EventWaitHandle とほぼ同等のことを実現しています。
ですので EventWaitHandle をわざわざ実装しなくともさほど困りません。
それでもあえて実装してみました。勿論理由はあります。
まずは先ほどのタイムアウト無しの方の最終コードを、EventWaitHandle を使って書き直してみます。
変更前のコードはこちらです。
public async foo() {
const hoge = new Hoge();
await new Promise(resolve => hoge.on("prepared", resolve));
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
await new Promise(resolve => fuga.on("prepared", resolve));
this._fuga = fuga;
console.log("The fuga is ready.");
}
続いて EventWaitHandle を使って書き直したコードです。
public async foo() {
const hoge = new Hoge();
const hogeWaitHandle = new EventWaitHandle();
hoge.on("prepared", hogeWaitHandle.set);
await hogeWaitHandle.wait();
this._hoge = hoge;
console.log("The hoge is ready.");
const fuga = new Fuga();
const fugaWaitHandle = new EventWaitHandle();
fuga.on("prepared", fugaWaitHandle.set);
await fugaWaitHandle.wait();
this._fuga = fuga;
console.log("The fuga is ready.");
}
コード量が増えてしまいましたね。
EventWaitHandle では、インスタンス生成、シグナルのセット、そしてシグナルの待機の 3 つの処理を分けて書く必要があるためです。
これだけで言うと、EventWaitHandle の利点はほぼありません。
では次に、タイムアウト有りの方の最終コードを EventWaitHandle を使って書き直してみます。
変更前のコードはこちらです。
public async foo() {
const hoge = new Hoge();
const hogeIsReady = await new Promise((resolve) => {
setTimeout(() => resolve(false), 5000);
hoge.on("prepared", () => resolve(true));
});
this._hoge = hoge;
console.log(`The hoge is ${hogeIsReady ? "ready" : "not ready"}.`);
const fuga = new Fuga();
const fugaIsReady = await new Promise((resolve) => {
setTimeout(() => resolve(false), 5000);
fuga.on("prepared", () => resolve(true));
});
this._fuga = fuga;
console.log(`The fuga is ${fugaIsReady ? "ready" : "not ready"}.`);
}
続いて EventWaitHandle を使って書き直したコードです。
public async foo() {
const hoge = new Hoge();
const hogeWaitHandle = new EventWaitHandle();
hoge.on("prepared", hogeWaitHandle.set);
const hogeIsReady = await hogeWaitHandle.wait(5000));
this._hoge = hoge;
console.log(`The hoge is ${hogeIsReady ? "ready" : "not ready"}.`);
const fuga = new Fuga();
const fugaWaitHandle = new EventWaitHandle();
fuga.on("prepared", fugaWaitHandle.set);
const fugaIsReady = await fugaWaitHandle.wait(5000));
this._fuga = fuga;
console.log(`The fuga is ${fugaIsReady ? "ready" : "not ready"}.`);
}
コード量やネストが減りました。
しかしそれ以上に、同期及びタイムアウトを実現するためのロジックが隠蔽されたことが重要です。
Promise を使った場合でもコード量は多くありませんが、どこまで行っても結局のところ、タイムアウトロジックを毎回その場で作り込んでいることには変わりありません。
EventWaitHandle を使うことで、読みやすさだけでなく書きやすさも向上しているというわけです。
ちなみに、wait メソッドの第二引数に true を指定することでタイムアウトをエラーとして検出するようになっていますので、タイムアウトをエラーにしたい場合もシンプルに記述できます。
では、最後になりましたが EventWaitHandle クラスの実装です。
内部のシグナル状態の管理にも Promise を使います。
TypeScript はシングルスレッドで動作するおかげで排他制御がいらず実装が非常に簡単です。
/**
* EventWaitHandle class.
*/
export class EventWaitHandle {
/**
* Initializes a new instance of EventWaitHandle class.
*/
constructor() {
this.reset();
}
private _signal?: Promise<undefined>;
private _resolveSignal?: () => void;
private _rejectSignal?: (reason?: any) => void;
/**
* Sets the state to nonsignaled.
*/
public reset() {
if (this._rejectSignal !== undefined) {
this._rejectSignal(new Error("The event wait handle is reset."));
}
this._signal = new Promise((resolve, reject) => {
this._resolveSignal = resolve;
this._rejectSignal = reject;
});
}
/**
* Sets the state to signaled.
*/
public set() {
if (this._resolveSignal !== undefined) {
this._resolveSignal();
}
}
/**
* Waits until the state is set to signaled.
* @param timeoutMilliSeconds The number of milliseconds to wait.
* @param detectsTimeoutAsError The value that indicates wheter a time out should be detected as an error.
* @throws Error A time out is detected. (Only if 'detectsTimeoutAsError' is set to 'true'.)
* @throws Error The wait handle is cleared.
*/
public async wait(timeoutMilliSeconds = -1, detectsTimeoutAsError = false) {
return await new Promise<boolean>((resolve, reject) => {
if (timeoutMilliSeconds >= 0) {
setTimeout(
detectsTimeoutAsError ? () => reject(new Error("A time out is detected.")) : () => resolve(false),
timeoutMilliSeconds
);
}
this._signal!.then(() => {
resolve(true);
}).catch((reason) => {
reject(reason);
});
});
}
}
WaitAll や WaitAny のような複数の EventWaitHandle をまとめて待機する静的メソッドは用意していませんが、wait メソッドが Promise を返しますので、代わりに次の Promise の静的メソッドを利用することができます。
メソッド | 概要 |
---|---|
all | 渡された全ての Promise が解決されるか、渡されたいずれかの Promise が拒否されるまで待機。 |
allSettled | 渡された全ての Promise が解決または拒否されるまで待機。 |
any2 | 渡されたいずれかの Promise が解決されるか、渡された全ての Promise が拒否されるまで待機。 |
race | 渡されたいずれかの Promise が解決または拒否されるまで待機。 |
最後に
TypeScript では EventWaitHandle を使わなくても Promise や setTimeout を使って同等のことができますが、EventWaitHandle を使うことでより読みやすさ・書きやすさが向上します。
メンバー全員が Promise や setTimeout を適切にシンプルに使いこなせる場合は特に不要かもしれませんが、そうでない場合、特に非同期処理を多用するプロジェクトでは、導入を検討する価値があるかと思います。