JavaScript 通常被描述為 單執行緒,這表示它一次執行一項任務。但這是否意味著每段程式碼都完全隔離運行,在等待 HTTP 回應或資料庫請求等非同步操作時無法處理其他任務?答案是不行! 事實上,JavaScript 的事件循環和 Promise 允許它在其他程式碼繼續運行的同時高效地處理非同步任務。
事實是 javascript 確實是單線程的,但是,誤解其工作原理可能會導致常見的陷阱。其中一個陷阱是管理 API 請求等非同步操作,尤其是在嘗試控制對共享資源的存取而不引起競爭條件時。讓我們探索一個現實世界的範例,看看糟糕的實施如何導致嚴重的錯誤。
我在應用程式中遇到一個錯誤,需要登入後端服務才能更新資料。登入後,應用程式將收到具有指定到期日的存取權杖。一旦過期日期過去,我們需要在向更新端點發出任何新請求之前重新進行身份驗證。出現這項挑戰是因為登入端點被限制為每五分鐘最多一個請求,而更新端點需要在同一五分鐘視窗內更頻繁地呼叫。邏輯正常運作至關重要,但登入端點偶爾會在五分鐘間隔內多次觸發,導致更新端點無法運作。雖然有時一切都按預期運行,但這種間歇性錯誤帶來了更嚴重的風險,因為它一開始可能會給人一種錯誤的安全感,讓人覺得系統運作正常。 _
為了說明此範例,我們使用一個非常基本的 NestJS 應用程序,其中包含以下服務:
我不會在這裡展示所有這些類別的程式碼;您可以直接在 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 偵測到存取權杖不存在時,它會檢查登入是否正在進行。如果是,函數將等待一秒鐘,然後重試。但是,如果在此期間收到另一個請求,則可能會觸發額外的登入嘗試。這可能會導致對登入端點的多次調用,該端點被限制為每分鐘只允許一次調用。因此,更新端點可能會間歇性失敗,從而導致不可預測的行為,並在系統有時看似正常運作時產生錯誤的安全感。
總而言之,問題在於非同步操作處理不當,這會導致潛在的競爭條件,從而破壞應用程式的邏輯。
@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
cd nestjs-singlethread-trap npm install
要使用好的版本模擬兩個請求,請呼叫:
npm run start
雖然 JavaScript 是單執行緒的,但它可以使用 Promise 和事件循環有效地處理非同步任務,例如 HTTP 請求。然而,對這些承諾的不當處理,特別是在涉及共享資源(如代幣)的場景中,可能會導致競爭條件和重複操作。
關鍵要點是同步登入等非同步操作,以避免此類陷阱。始終確保您的程式碼了解正在進行的進程,並以確保正確排序的方式處理請求,即使 JavaScript 在幕後執行多任務也是如此。
如果您還沒有加入 Rabbit Byte Club,那麼現在您有機會加入由軟體愛好者、技術創始人和非技術創始人組成的蓬勃發展的社群。我們一起分享知識,互相學習,並準備建立下一個大型新創公司。今天就加入我們,成為令人興奮的創新和成長之旅的一部分!
以上是如何避免 JavaScript 中的單線程陷阱的詳細內容。更多資訊請關注PHP中文網其他相關文章!