Heim  >  Artikel  >  Web-Frontend  >  So vermeiden Sie die Single-Threaded-Falle in JavaScript

So vermeiden Sie die Single-Threaded-Falle in JavaScript

Patricia Arquette
Patricia ArquetteOriginal
2024-11-02 17:10:02821Durchsuche

How to Avoid the Single-Threaded Trap in JavaScript

JavaScript wird oft als Single-Threaded beschrieben, was bedeutet, dass es jeweils eine Aufgabe ausführt. Aber bedeutet das, dass jeder Codeabschnitt völlig isoliert läuft und keine Möglichkeit hat, andere Aufgaben zu erledigen, während er auf asynchrone Vorgänge wie HTTP-Antworten oder Datenbankanfragen wartet? Die Antwort ist Nein! Tatsächlich ermöglichen die Ereignisschleife und Versprechen von JavaScript die effiziente Verarbeitung asynchroner Aufgaben, während anderer Code weiterhin ausgeführt wird.

Die Wahrheit ist, dass Javascript tatsächlich Single-Threaded ist, jedoch kann ein Missverständnis darüber, wie dies funktioniert, zu häufigen Fallstricken führen. Eine solche Falle ist die Verwaltung asynchroner Vorgänge wie API-Anfragen, insbesondere wenn versucht wird, den Zugriff auf gemeinsam genutzte Ressourcen zu kontrollieren, ohne Race Conditions zu verursachen. Lassen Sie uns ein Beispiel aus der Praxis untersuchen und sehen, wie eine schlechte Implementierung zu schwerwiegenden Fehlern führen kann.

Ich bin auf einen Fehler in einer Anwendung gestoßen, der eine Anmeldung bei einem Backend-Dienst erforderlich machte, um Daten zu aktualisieren. Beim Anmelden erhält die App ein Zugriffstoken mit einem angegebenen Ablaufdatum. Nach Ablauf dieses Ablaufdatums mussten wir uns erneut authentifizieren, bevor wir neue Anfragen an den Update-Endpunkt stellen konnten. Die Herausforderung entstand, weil der Anmeldeendpunkt auf maximal eine Anfrage alle fünf Minuten gedrosselt wurde, während der Aktualisierungsendpunkt innerhalb desselben Fünf-Minuten-Fensters häufiger aufgerufen werden musste. Es war wichtig, dass die Logik ordnungsgemäß funktionierte, dennoch wurde der Anmeldeendpunkt innerhalb des Fünf-Minuten-Intervalls gelegentlich mehrmals ausgelöst, was dazu führte, dass der Aktualisierungsendpunkt nicht funktionierte. Obwohl es Zeiten gab, in denen alles wie erwartet funktionierte, stellte dieser zeitweise auftretende Fehler ein größeres Risiko dar, da er zunächst ein falsches Sicherheitsgefühl vermitteln und den Eindruck erwecken konnte, das System würde ordnungsgemäß funktionieren._

Um dieses Beispiel zu veranschaulichen, verwenden wir eine sehr einfache NestJS-App, die die folgenden Dienste umfasst:

  • AppService: Fungiert als Controller, um zwei Varianten zu simulieren – die schlechte Version, die manchmal funktioniert und manchmal nicht, und die gute Version, die garantiert immer ordnungsgemäß funktioniert.
  • BadAuthenticationService: Implementierung für die fehlerhafte Version.
  • GoodAuthenticationService: Implementierung für die gute Version.
  • AbstractAuthenticationService: Klasse, die für die Aufrechterhaltung des gemeinsamen Status zwischen GoodAuthenticationService und BadAuthenticationService verantwortlich ist.
  • LoginThrottleService: Klasse, die den Drosselungsmechanismus des Anmeldeendpunkts für den Backend-Dienst simuliert.
  • MockHttpService: Klasse, die bei der Simulation von HTTP-Anfragen hilft.
  • MockAwsCloudwatchApiService: Simuliert einen API-Aufruf an das AWS CloudWatch-Protokollierungssystem.

Ich werde hier nicht den Code für alle diese Klassen zeigen; Sie finden es direkt im GitHub-Repository. Stattdessen werde ich mich speziell auf die Logik konzentrieren und darauf, was geändert werden muss, damit sie richtig funktioniert.

Der schlechte Ansatz:

@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;
    }
  }
}

Warum dies ein schlechter Ansatz ist:

Im BadAuthenticationService setzt die Methode loginToBackendService this.loginInProgress auf true, wenn eine Anmeldeanforderung initiiert wird. Da diese Methode jedoch asynchron ist, kann nicht garantiert werden, dass der Anmeldestatus sofort aktualisiert wird. Dies könnte innerhalb des Drosselungslimits zu mehreren gleichzeitigen Aufrufen des Anmeldeendpunkts führen.
Wenn sendProtectedRequest erkennt, dass das Zugriffstoken fehlt, prüft es, ob gerade eine Anmeldung durchgeführt wird. Ist dies der Fall, wartet die Funktion eine Sekunde und versucht es dann erneut. Wenn in dieser Zeit jedoch eine weitere Anfrage eintrifft, kann dies zu weiteren Anmeldeversuchen führen. Dies kann zu mehreren Aufrufen des Anmeldeendpunkts führen, der so gedrosselt ist, dass nur ein Anruf pro Minute möglich ist. Infolgedessen kann der Update-Endpunkt zeitweise ausfallen, was zu unvorhersehbarem Verhalten und einem falschen Sicherheitsgefühl führt, wenn das System zeitweise ordnungsgemäß zu funktionieren scheint.

Zusammenfassend lässt sich sagen, dass das Problem in der unsachgemäßen Handhabung asynchroner Vorgänge liegt, die zu potenziellen Race Conditions führt, die die Logik der Anwendung zerstören können.

Der gute Ansatz:

@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;
    }
  }
}

Warum dies ein guter Ansatz ist:

Im GoodAuthenticationService ist die loginToBackendService-Methode so strukturiert, dass sie die Anmeldelogik effizient verarbeitet. Die wichtigste Verbesserung ist die Verwaltung des loginInProgress-Flags. Es wird nach der Bestätigung, dass kein Zugriffstoken vorhanden ist, und bevor jegliche asynchronen Vorgänge festgelegt. Dadurch wird sichergestellt, dass nach dem Einleiten eines Anmeldeversuchs keine anderen Anmeldeaufrufe gleichzeitig erfolgen können, wodurch mehrere Anfragen an den gedrosselten Anmeldeendpunkt effektiv verhindert werden.

Demo-Anweisungen

Klonen Sie das Repository:

@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;
    }
  }
}

Installieren Sie die erforderlichen Abhängigkeiten:

@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;
    }
  }
}

Führen Sie die Anwendung aus:

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

Anfragen simulieren:

  • Um zwei Anfragen mit der schlechten Version zu simulieren, rufen Sie auf:
cd nestjs-singlethread-trap
npm install

Um zwei Anfragen mit der guten Version zu simulieren, rufen Sie an:

npm run start

Fazit: Die Single-Threaded-Fallstricke von JavaScript vermeiden

Obwohl JavaScript Single-Threaded ist, kann es asynchrone Aufgaben wie HTTP-Anfragen mithilfe von Versprechen und der Ereignisschleife effizient verarbeiten. Allerdings kann ein unsachgemäßer Umgang mit diesen Versprechen, insbesondere in Szenarien mit gemeinsam genutzten Ressourcen (wie Token), zu Race Conditions und doppelten Aktionen führen.
Die wichtigste Erkenntnis besteht darin, asynchrone Aktionen wie Anmeldungen zu synchronisieren, um solche Fallen zu vermeiden. Stellen Sie immer sicher, dass Ihr Code über laufende Prozesse informiert ist und Anfragen auf eine Weise verarbeitet, die eine ordnungsgemäße Reihenfolge gewährleistet, auch wenn JavaScript hinter den Kulissen Multitasking betreibt.

Wenn Sie dem Rabbit Byte Club noch nicht beigetreten sind, haben Sie jetzt die Chance, Teil einer lebendigen Community aus Software-Enthusiasten, Tech-Gründern und Nicht-Tech-Gründern zu werden. Gemeinsam tauschen wir Wissen aus, lernen voneinander und bereiten uns auf den Aufbau des nächsten großen Startups vor. Begleiten Sie uns noch heute und seien Sie Teil einer spannenden Reise zu Innovation und Wachstum!

Das obige ist der detaillierte Inhalt vonSo vermeiden Sie die Single-Threaded-Falle in JavaScript. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn