ホームページ  >  記事  >  ウェブフロントエンド  >  JavaScript でシングルスレッドのトラップを回避する方法

JavaScript でシングルスレッドのトラップを回避する方法

Patricia Arquette
Patricia Arquetteオリジナル
2024-11-02 17:10:02821ブラウズ

How to Avoid the Single-Threaded Trap in JavaScript

JavaScript は、しばしば シングルスレッド として説明されます。これは、一度に 1 つのタスクを実行することを意味します。しかし、これは、すべてのコードが完全に分離されて実行され、HTTP 応答やデータベース リクエストなどの非同期操作を待機している間は他のタスクを処理する機能がないことを意味するのでしょうか?答えはいいえ! 実際、JavaScript のイベント ループと Promise により、他のコードの実行を継続しながら非同期タスクを効率的に処理できます。

実際には、JavaScript は確かにシングルスレッドですが、その仕組みを誤解すると、よくある落とし穴につながる可能性があります。そのような罠の 1 つは、特に競合状態を引き起こさずに共有リソースへのアクセスを制御しようとする場合に、API リクエストなどの非同期操作を管理することです。実際の例を見て、実装が不十分な場合に重大なバグがどのように発生するかを見てみましょう。

データを更新するためにバックエンド サービスにログインする必要があるアプリケーションでバグが発生しました。ログインすると、アプリは指定された有効期限を持つアクセス トークンを受け取ります。この有効期限が過ぎると、更新エンドポイントに新しいリクエストを行う前に再認証する必要がありました。この課題は、ログイン エンドポイントが 5 分ごとに最大 1 つのリクエストに制限される一方、更新エンドポイントは同じ 5 分間の時間枠内でより頻繁に呼び出される必要があるために発生しました。ロジックが正しく機能することが重要でしたが、ログイン エンドポイントが 5 分以内に時折複数回トリガーされ、更新エンドポイントが機能しなくなってしまいました。すべてが期待どおりに機能する場合もありましたが、この断続的なバグはより深刻なリスクをもたらしました。最初は誤った安心感を与え、システムが適切に動作しているように見える可能性があるからです。_

この例を説明するために、次のサービスを含む非常に基本的な NestJS アプリを使用します。

  • AppService: 2 つのバリアントをシミュレートするコントローラーとして機能します。1 つは動作することもあるが動作しないこともあり、もう 1 つは常に適切に動作することが保証されている良好なバージョンです。
  • BadAuthenticationService: 不正なバージョンの実装。
  • GoodAuthenticationService: 良いバージョンの実装。
  • AbstractAuthenticationService: GoodAuthenticationService と BadAuthenticationService の間の共有状態を維持するクラスです。
  • LoginThrottleService: バックエンド サービスのログイン エンドポイントのスロットル メカニズムをシミュレートするクラス。
  • MockHttpService: HTTP リクエストのシミュレートに役立つクラス。
  • MockAwsCloudwatchApiService: AWS CloudWatch ログ システムへの API 呼び出しをシミュレートします。

ここではこれらすべてのクラスのコードを示しません。 GitHub リポジトリで直接見つけることができます。代わりに、ロジックと、それが正しく動作するために何を変更する必要があるかに特に焦点を当てます。

悪いアプローチ:

@Injectable()
export class BadAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    this.loginInProgress = true; // this is BAD, we are inside a promise, it's asynchronous. it's not synchronous, javascript can execute it whenever it wants

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}

これが間違ったアプローチである理由:

BadAuthenticationService では、ログイン要求を開始するときに、loginToBackendService メソッドが this.loginInProgress を true に設定します。ただし、このメソッドは非同期であるため、ログイン状態がすぐに更新されることは保証されません。これにより、スロットル制限内でログイン エンドポイントへの複数の同時呼び出しが発生する可能性があります。
sendProtectedRequest は、アクセス トークンが存在しないことを検出すると、ログインが進行中かどうかを確認します。存在する場合、関数は 1 秒待ってから再試行します。ただし、この間に別のリクエストが受信されると、追加のログイン試行がトリガーされる可能性があります。これにより、ログイン エンドポイントへの複数の呼び出しが発生する可能性があり、1 分ごとに 1 つの呼び出しのみが許可されるように調整されます。その結果、更新エンドポイントが断続的に失敗する可能性があり、システムが正常に機能しているように見えても、予期せぬ動作や誤った安心感を引き起こすことがあります。

要約すると、問題は非同期操作の不適切な処理にあり、これがアプリケーションのロジックを破壊する可能性のある競合状態を引き起こす可能性があります。

良いアプローチ:

@Injectable()
export class GoodAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      // Critical: Set the flag before ANY promise call
      this.loginInProgress = true;

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}

これが良いアプローチである理由:

GoodAuthenticationService では、loginToBackendService メソッドはログイン ロジックを効率的に処理するように構造化されています。主な改善点は、loginInProgress フラグの管理です。これは、アクセス トークンが存在しないことを確認した、および非同期操作が開始されるに設定されます。これにより、ログイン試行が開始されると、他のログイン呼び出しを同時に行うことができなくなり、スロットルされたログイン エンドポイントへの複数のリクエストが効果的に防止されます。

デモの手順

リポジトリのクローンを作成します。

@Injectable()
export class BadAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    this.loginInProgress = true; // this is BAD, we are inside a promise, it's asynchronous. it's not synchronous, javascript can execute it whenever it wants

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}

必要な依存関係をインストールします。

@Injectable()
export class GoodAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      // Critical: Set the flag before ANY promise call
      this.loginInProgress = true;

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}

アプリケーションを実行します。

git clone https://github.com/zenstok/nestjs-singlethread-trap.git

リクエストをシミュレートします。

  • 不正なバージョンで 2 つのリクエストをシミュレートするには、次のように呼び出します。
cd nestjs-singlethread-trap
npm install

良いバージョンで 2 つのリクエストをシミュレートするには、次のように呼び出します。

npm run start

結論: JavaScript のシングルスレッドの落とし穴を回避する

JavaScript はシングルスレッドですが、Promise とイベント ループを使用して HTTP リクエストなどの非同期タスクを効率的に処理できます。ただし、特に共有リソース (トークンなど) が関係するシナリオでは、これらの Promise を不適切に処理すると、競合状態や重複アクションが発生する可能性があります。
重要な点は、ログインなどの非同期アクションを同期して、このようなトラップを回避することです。 JavaScript がバックグラウンドでマルチタスクを実行している場合でも、コードが進行中のプロセスを常に認識し、適切な順序が保証される方法でリクエストを処理するようにしてください。

Rabbit Byte Club にまだ参加していない場合は、ソフトウェア愛好家、テクノロジー創設者、非テクノロジー創設者の活気に満ちたコミュニティに参加するチャンスです。私たちは一緒に知識を共有し、お互いから学び、次の大きなスタートアップを構築する準備をします。今すぐ参加して、イノベーションと成長に向けたエキサイティングな旅に参加してください!

以上がJavaScript でシングルスレッドのトラップを回避する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。