如何解決Nodejs中CPU密集型的任務(wù)
本文轉(zhuǎn)載自微信公眾號(hào)「編程雜技」,作者theanarkh 。轉(zhuǎn)載本文請(qǐng)聯(lián)系編程雜技公眾號(hào)。
一. 方案對(duì)比
二. 其他的線程池方案
1 Libuv和nginx的線程池:線程數(shù)固定,多個(gè)線程共享一個(gè)任務(wù)隊(duì)列,沒(méi)有任務(wù)時(shí)主動(dòng)掛起,不會(huì)主動(dòng)退出。
2 Java:線程數(shù)運(yùn)行時(shí)可以動(dòng)態(tài)增加,支持空閑退出、任務(wù)過(guò)載多種處理策略,多種類(lèi)型的線程池。
三. 訴求
1 提交一個(gè)js文件處理cpu型任務(wù),這樣比較方便。而不是傳一個(gè)函數(shù),需要經(jīng)過(guò)各種序列化反序列化。
2 一個(gè)全局的線程池,可以支持多種類(lèi)型的任務(wù),類(lèi)似libuv線程池
3 空閑過(guò)久的線程可以主動(dòng)退出
4 任務(wù)過(guò)載可以動(dòng)態(tài)擴(kuò)展線程數(shù)
Nodejs線程池的調(diào)研:
1 machenjie/node-thread-pool 任務(wù)只能是代碼字符串,固定線程數(shù),不支持空閑線程主動(dòng)退出
2 Truth1984/thread_pools 任務(wù)只能是代碼字符串,沒(méi)有實(shí)現(xiàn)池化,每次創(chuàng)建一個(gè)線程,執(zhí)行完任務(wù)退出。
3 bruno303/node-workers-pool 任務(wù)只能是代碼字符串,不支持空閑退出
4 zebrajaeger/threadpool 不是線程池的概念 5
psastras/node-threadpool 沒(méi)有實(shí)現(xiàn)池化,不支持空閑退出
6 node-worker-threads-pool 周下載量20k左右,star 80。任務(wù)只能是代碼字符串,不支持空閑線程退出,固定線程數(shù)
7 threads 周下載量20k左右,star 1.1k 是對(duì)線程模塊的封裝,沒(méi)有實(shí)現(xiàn)池化能力
8 poolifier 周下載量5000左右,star 59,任務(wù)可以是js文件,一個(gè)類(lèi)型的任務(wù)新建一個(gè)線程池,無(wú)法共享線程池
目前的npm包看起來(lái)還不太能滿(mǎn)足需求。所以決定寫(xiě)一個(gè)。
四.線程池的設(shè)計(jì)需要考慮的問(wèn)題
1 對(duì)于純cpu型的任務(wù),線程數(shù)和cpu核數(shù)要相等才能達(dá)到最優(yōu)的性能,否則過(guò)多的線程引起的上下文切換反而會(huì)導(dǎo)致性能下降。
2 對(duì)于io型的任務(wù),更多的線程理論上是會(huì)更好,因?yàn)榭梢愿绲亟o硬盤(pán)發(fā)出命令,磁盤(pán)會(huì)優(yōu)化并持續(xù)地處理請(qǐng)求。當(dāng)然,線程數(shù)也不是越多越好。線程過(guò)多會(huì)引起系統(tǒng)負(fù)載過(guò)高,過(guò)多上下文切換也會(huì)帶來(lái)性能的下降。
3 使用方便、簡(jiǎn)單
整體架構(gòu)(原圖[1])
五. 設(shè)計(jì)思想
1 任務(wù)隊(duì)列的設(shè)計(jì)
1.1傳統(tǒng)的線程池設(shè)計(jì) 維護(hù)一個(gè)共享的任務(wù)隊(duì)列,然后多個(gè)線程通過(guò)加鎖互斥的方式訪問(wèn)該隊(duì)列,取出任務(wù)執(zhí)行。比如libuv,nginx。
1.2 我們的設(shè)計(jì) 因?yàn)槲覀兪峭ㄟ^(guò)js使用nodejs線程池的,隊(duì)列也是使用js數(shù)據(jù)結(jié)構(gòu)表示的。所以我們無(wú)法通過(guò)加鎖的方式互斥訪問(wèn)共享隊(duì)列。這就會(huì)引起競(jìng)態(tài)條件。我們使用的方式是,每個(gè)子線程維護(hù)自己的任務(wù)隊(duì)列,調(diào)度中心把任務(wù)提交給子線程,子線程自己插入所維護(hù)的隊(duì)列中。
2 線程類(lèi)型和任務(wù)數(shù) 把線程分為核心線程和替補(bǔ)線程。分為幾個(gè)關(guān)鍵的概念:子線程當(dāng)前的任務(wù)數(shù),線程池的總?cè)蝿?wù)數(shù)、核心線程數(shù)和最大線程數(shù)。在總?cè)蝿?wù)數(shù)還沒(méi)有得到閾值時(shí),所有任務(wù)都由核心線程處理,達(dá)到閾值后,會(huì)創(chuàng)建替補(bǔ)線程處理。
3 過(guò)載處理策略和選擇線程的策略 任務(wù)過(guò)載時(shí),就會(huì)觸發(fā)過(guò)載處理策略。分為報(bào)錯(cuò)、在主線程執(zhí)行任務(wù)、繼續(xù)交給子線程處理、刪除最老的任務(wù)。選擇線程的策略為選擇任務(wù)數(shù)最少的線程。
4 空閑策略 當(dāng)沒(méi)有任務(wù)可處理的時(shí)候,線程池的線程怎么辦?
4.1 傳統(tǒng)的設(shè)計(jì) 使用條件變量機(jī)制,把線程阻塞在條件變量中,這時(shí)候操作系統(tǒng)不會(huì)調(diào)度該線程執(zhí)行,所以不會(huì)浪費(fèi)cpu,等到有新任務(wù)到來(lái)時(shí),主線程會(huì)喚醒被阻塞的子線程。不過(guò)阻塞的線程依然占據(jù)著系統(tǒng)資源,如果一直沒(méi)有任務(wù),則浪費(fèi)資源。
4.2 我們的設(shè)計(jì) 我們?cè)趈s層無(wú)法像底層線程一樣使用條件變量,所以我們無(wú)法阻塞自己,這就意味著我們會(huì)一直在空轉(zhuǎn)、浪費(fèi)資源。所以我們?cè)O(shè)計(jì)了線程的空閑退出時(shí)間,達(dá)到這個(gè)時(shí)間后,線程退出。盡快釋放資源。
5 如何設(shè)計(jì)用戶(hù)和線程池的通信 用戶(hù)提交任務(wù)后,如果知道任務(wù)什么時(shí)候執(zhí)行完?如何拿到執(zhí)行結(jié)果?執(zhí)行任務(wù)的時(shí)候,參數(shù)如何傳進(jìn)去?
5.1 傳統(tǒng)的設(shè)計(jì) 用戶(hù)把需要處理的邏輯封裝到函數(shù)中,然后子線程中阻塞時(shí)執(zhí)行,執(zhí)行完后,同步拿到結(jié)果。
5.2 我們的設(shè)計(jì) 但是在nodejs中不太一樣。Nodejs使用work_thread模塊創(chuàng)建的線程,其實(shí)是一個(gè)和主線程獨(dú)立的事件循環(huán)。所以我們?cè)谧泳€程里執(zhí)行任務(wù)時(shí),其實(shí)就相當(dāng)于在執(zhí)行一個(gè)nodejs的實(shí)例,這就意味著我們可以以同步和異步的方式編程我們?nèi)蝿?wù)函數(shù)代碼。那么以異步方式進(jìn)行處理的任務(wù),我們?nèi)绾文玫浇Y(jié)果?為了解決以上問(wèn)題,我們使用函數(shù)和Promise方案。用戶(hù)提交的任務(wù)具體表現(xiàn)為一個(gè)返回Promise的函數(shù),使用函數(shù)是因?yàn)槲覀兛梢栽谔幚砣蝿?wù)(執(zhí)行函數(shù))時(shí),把用戶(hù)自定義的參數(shù)傳進(jìn)去,使用Promise可以等到用戶(hù)返回的Promise決議時(shí),拿到返回的值,從而返回給用戶(hù)。
具體實(shí)現(xiàn):用戶(hù)定義的邏輯test.js
- module.exports = function() {
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- resolve({code: 0});
- },3000)
- })
- }
子線程邏輯
- const result = await require('./test')(options);
六.成果
線程池支持的參數(shù)
- 1 coreThreads:核心線程數(shù),默認(rèn)10個(gè)
- 2 maxThreads:最大線程數(shù),默認(rèn)50,只在支持動(dòng)態(tài)擴(kuò)容的情況下,該參數(shù)有效,否則該參數(shù)等于核心線程數(shù)
- 3 sync:線程處理任務(wù)的模式,同步則串行處理任務(wù),異步則并行處理任務(wù),不同步等待用戶(hù)代碼的執(zhí)行結(jié)果
- 4 discardPolicy:任務(wù)超過(guò)閾值時(shí)的處理策略,策略如下
- 5 preCreate:是否預(yù)創(chuàng)建線程池
- 6 maxIdleTime:線程空閑多久后自動(dòng)退出
- 7 pollIntervalTime:線程隔多久輪詢(xún)是否有任務(wù)需要處理
- 8 maxWork:線程池最大任務(wù)數(shù)
- 9 expansion:是否支持動(dòng)態(tài)擴(kuò)容線程,閾值是最大線程數(shù)
支持的線程池類(lèi)型
- // 串行處理任務(wù)隊(duì)列里的任務(wù)
- const defaultSyncThreadPool = new SyncThreadPool();
- // 并行處理任務(wù)隊(duì)列里的任務(wù)
- const defaultAsyncThreadPool = new AsyncThreadPool();
- // 針對(duì)cpu密集型任務(wù)的線程池,線程數(shù)等于cpu核數(shù)
- const defaultCpuThreadPool = new CPUThreadPool();
- // 線程數(shù)固定的線程池
- const defaultFixedThreadPool = new FixedThreadPool();
- // 只有一個(gè)線程的線程池,任務(wù)在線程池中按序執(zhí)行
- const defaultSingleThreadPool = new SingleThreadPool();
七. 使用方式
方式1
nodejs子線程和nodejs主線程共享一個(gè)libuv線程池,如果在子線程中使用了libuv的線程池,會(huì)和主線程競(jìng)爭(zhēng)libuv子線程。從而影響主線程的任務(wù)執(zhí)行。如果是純cpu的計(jì)算,則可以這樣使用。下面是這種使用方式下,nodejs的架構(gòu)。
方式2
在nodejs主進(jìn)程外開(kāi)啟一個(gè)新的進(jìn)程進(jìn)行任務(wù)的處理,和主進(jìn)程保持獨(dú)立,保證穩(wěn)定性的同時(shí),也不會(huì)和主進(jìn)程競(jìng)爭(zhēng)libuv的線程。如果在子線程中需要用到libuv線程池,則使用方式2比較好。下面是方式2對(duì)應(yīng)的nodejs架構(gòu)。
八. 具體例子
References
[1] 原圖: https://www.processon.com/view/link/5f53a187e401fd60bde1bab1
[2] github地址: https://github.com/theanarkh/nodejs-threadpool