مقدمه ای بر Node.js Multithreading
زمان اجرا جاوا اسکریپت از یک رشته پردازشی واحد استفاده می کند. موتور در یک زمان یک کار را انجام می دهد و قبل از اینکه بتواند هر کار دیگری را انجام دهد باید اجرا را کامل کند. این به ندرت باعث ایجاد مشکل در مرورگر می شود، زیرا یک کاربر با برنامه تعامل دارد. اما برنامه های Node.js می توانند صدها درخواست کاربر را مدیریت کنند. Multithreading می تواند از ایجاد گلوگاه در برنامه شما جلوگیری کند.
یک برنامه وب Node.js را در نظر بگیرید که در آن یک کاربر می تواند یک محاسبه پیچیده جاوا اسکریپت ده ثانیه ای را راه اندازی کند. تا زمانی که این محاسبه کامل نشود، برنامه قادر به رسیدگی به درخواستهای دریافتی از سایر کاربران نیست.
زبانهایی مانند PHP و Python نیز تک رشتهای هستند، اما معمولاً از یک وب سرور چند رشتهای استفاده میکنند که در هر درخواست، نمونه جدیدی از مفسر را راهاندازی میکند. این یک منبع فشرده است، پس برنامه های Node.js اغلب وب سرور سبک خود را ارائه می دهند.
یک وب سرور Node.js روی یک رشته اجرا می شود، اما جاوا اسکریپت مشکلات عملکرد را با حلقه رویداد غیر مسدود کننده خود کاهش می دهد. برنامه ها می توانند به طور ناهمزمان عملیات هایی مانند فایل، پایگاه داده و HTTP را که روی رشته های دیگر سیستم عامل اجرا می شوند، اجرا کنند. حلقه رویداد ادامه مییابد و میتواند سایر وظایف جاوا اسکریپت را در حالی که منتظر است تا عملیات I/O تکمیل شود، انجام دهد.
متأسفانه، کدهای جاوا اسکریپت طولانی مدت - مانند پردازش تصویر - می تواند تکرار فعلی حلقه رویداد را مختل کند. این مقاله نحوه انتقال پردازش به رشته دیگر را با استفاده از:
Node.js Worker Threads
موضوعات Worker معادل Node.js کارگران وب هستند. رشته اصلی داده ها را به اسکریپت دیگری ارسال می کند که (به طور ناهمزمان) آن را در یک رشته جداگانه پردازش می کند. رشته اصلی به اجرای خود ادامه می دهد و زمانی که کارگر کار خود را به پایان رساند، یک رویداد برگشتی را اجرا می کند.
دیگر اخبار
دستگاههای MacOS توسط برنامههای دزدی که میخواهند دستگاه شما را هک کنند هدف قرار میگیرند
توجه داشته باشید که جاوا اسکریپت از الگوریتم کلون ساخت یافته خود برای سریال سازی داده ها در یک رشته زمانی که به یک کارگر و از کارگر ارسال می شود استفاده می کند. این می تواند شامل انواع بومی مانند رشته ها، اعداد، بولی ها، آرایه ها و اشیاء باشد – اما نه توابع . شما نمی توانید اشیاء پیچیده را ارسال کنید - مانند اتصالات پایگاه داده - زیرا اکثر آنها روش هایی دارند که نمی توان آنها را شبیه سازی کرد. با این حال، شما می توانید:
داده های پایگاه داده را به صورت ناهمزمان در رشته اصلی بخوانید و داده های حاصل را به کارگر ارسال کنید.
یک شی اتصال دیگر در worker ایجاد کنید. این هزینه راه اندازی خواهد داشت، اما اگر عملکرد شما به درخواست های پایگاه داده بیشتری به عنوان بخشی از محاسبه نیاز دارد، ممکن است عملی باشد.
Node.js worker thread API از نظر مفهومی مشابه Web Workers API در مرورگر است، اما تفاوتهای نحوی وجود دارد. Deno و Bun از APIهای مرورگر و Node.js پشتیبانی می کنند.
تظاهرات نخ کارگر
نمایش زیر یک فرآیند Node.js را نشان می دهد که زمان جاری را در هر ثانیه روی کنسول می نویسد: نمایش Node.js را در یک تب جدید مرورگر باز کنید.
سپس محاسبه پرتاب تاس طولانی مدت روی رشته اصلی اجرا می شود. حلقه 100 میلیون تکرار را تکمیل می کند، که خروجی زمان را متوقف می کند:
timer process 12 :33:18 PM timer process 12 :33:19 PM timer process 12 :33:20 PM NO THREAD CALCULATION STARTED .. . ┌─────────┬──────────┐ │ ( index ) │ Values │ ├─────────┼──────────┤ │ 2 │ 2776134 │ │ 3 │ 5556674 │ │ 4 │ 8335819 │ │ 5 │ 11110893 │ │ 6 │ 13887045 │ │ 7 │ 16669114 │ │ 8 │ 13885068 │ │ 9 │ 11112704 │ │ 10 │ 8332503 │ │ 11 │ 5556106 │ │ 12 │ 2777940 │ └─────────┴──────────┘ processing time: 2961ms NO THREAD CALCULATION COMPLETE timer process 12 :33:24 PM
پس از تکمیل، همان محاسبه روی یک موضوع کارگر راه اندازی می شود. در حالی که پردازش تاس اتفاق می افتد، ساعت به کار خود ادامه می دهد:
WORKER CALCULATION STARTED .. . timer process 12 :33:27 PM timer process 12 :33:28 PM timer process 12 :33:29 PM ┌─────────┬──────────┐ │ ( index ) │ Values │ ├─────────┼──────────┤ │ 2 │ 2778246 │ │ 3 │ 5556129 │ │ 4 │ 8335780 │ │ 5 │ 11114930 │ │ 6 │ 13889458 │ │ 7 │ 16659456 │ │ 8 │ 13889139 │ │ 9 │ 11111219 │ │ 10 │ 8331738 │ │ 11 │ 5556788 │ │ 12 │ 2777117 │ └─────────┴──────────┘ processing time: 2643ms WORKER CALCULATION COMPLETE timer process 12 :33:30 PM
فرآیند کارگر کمی سریعتر از موضوع اصلی است زیرا می تواند روی یک کار متمرکز شود.
نحوه استفاده از نخ های کارگر
یک فایل dice.js
در پروژه نمایشی یک تابع پرتاب تاس را تعریف می کند. تعداد دویدن ها (پرتاب ها)، تعداد تاس ها، و تعداد طرف های هر قالب رد شده است. در هر پرتاب، تابع sum
تاس را محاسبه می کند و تعداد دفعاتی که در آرایه stat
مشاهده می شود را افزایش می دهد. وقتی همه پرتاب ها کامل شد، تابع آرایه را برمی گرداند:
export function diceRun ( runs = 1 , dice = 2 , sides = 6 ) { const stat = [ ] ; while ( runs > 0 ) { let sum = 0 ; for ( let d = dice ; d > 0 ; d -- ) { sum += Math . floor ( Math . random ( ) * sides ) + 1 ; } stat [ sum ] = ( stat [ sum ] || 0 ) + 1 ; runs -- ; } return stat ; }
اسکریپت اصلی index.js
یک فرآیند تایمر را شروع می کند که تاریخ و زمان فعلی را هر ثانیه خروجی می دهد:
const intlTime = new Intl . DateTimeFormat ( [ ] , { timeStyle : "medium" } ) ; timer = setInterval ( ( ) => { console . log ( ` timer process ${ intlTime . format ( new Date ( ) ) } ` ) ; } , 1000 ) ;
زمانی که thread اصلی diceRun()
را اجرا می کند، تایمر متوقف می شود زیرا هیچ چیز دیگری نمی تواند در حین انجام محاسبه اجرا شود:
import { diceRun } from "./dice.js" ; const throws = 100_000_000 , dice = 2 , sides = 6 ; const stat = diceRun ( throws , dice , sides ) ; console . table ( stat ) ;
برای اجرای محاسبات در یک رشته دیگر، کد یک شی Worker جدید با نام فایل اسکریپت worker تعریف می کند. این یک متغیر workerData
را ارسال می کند - یک شی با ویژگی های throws
، dice
و sides
:
const worker = new Worker ( "./src/worker.js" , { workerData : { throws , dice , sides } } ) ;
این اسکریپت worker را شروع می کند که diceRun()
با پارامترهای ارسال شده در workerData
اجرا می کند:
import { workerData , parentPort } from "node:worker_threads" ; import { diceRun } from "./dice.js" ; const stat = diceRun ( workerData . throws , workerData . dice , workerData . sides ) ; parentPort . postMessage ( stat ) ;
parentPort.postMessage(stat);
call نتیجه را به رشته اصلی ارسال می کند. این یک رویداد "message"
را در index.js
ایجاد می کند که نتیجه را دریافت کرده و در کنسول نمایش می دهد:
worker . on ( "message" , result => { console . table ( result ) ; } ) ;
میتوانید برای رویدادهای کارگری دیگر هندلر تعریف کنید:
اسکریپت اصلی می تواند از worker.postMessage(data)
برای ارسال داده های دلخواه به کارگر در هر نقطه استفاده کند. این یک رویداد "message"
را در اسکریپت کارگر ایجاد می کند:
parentPort . on ( "message" , data => { console . log ( "from main:" , data ) ; } ) ;
"messageerror"
زمانی در رشته اصلی فعال میشود که کارگر دادههایی را دریافت میکند که نمیتواند آنها را از حالت سریال خارج کند.
زمانی که thread کارگر شروع به اجرا می کند، "online"
در رشته اصلی فعال می شود.
هنگامی که یک خطای جاوا اسکریپت در thread کارگر رخ می دهد "error"
در رشته اصلی فعال می شود. می توانید از این برای خاتمه دادن به کارگر استفاده کنید. مثلا:
worker . on ( "error" , e => { console . log ( e ) ; worker . terminate ( ) ; } ) ;
هنگامی که کارگر خاتمه می یابد "exit"
در رشته اصلی فعال می شود. این می تواند برای تمیز کردن، ثبت نام، نظارت بر عملکرد و غیره استفاده شود:
worker . on ( "exit" , code => { console . log ( "worker complete" ) ; } ) ;
رشته های کارگر درون خطی
یک فایل اسکریپت واحد می تواند شامل کد اصلی و کارگر باشد. اسکریپت شما باید تحلیل کند که آیا با استفاده از isMainThread
روی رشته اصلی اجرا می شود یا خیر، سپس با استفاده از import.meta.url
به عنوان مرجع فایل در یک ماژول ES (یا __filename
در CommonJS) خود را به عنوان کارگر فراخوانی کند:
import { Worker , isMainThread , workerData , parentPort } from "node:worker_threads" ; if ( isMainThread ) { const worker = new Worker ( import . meta . url , { workerData : { throws , dice , sides } } ) ; worker . on ( "message" , msg => { } ) ; worker . on ( "exit" , code => { } ) ; } else { const stat = diceRun ( workerData . throws , workerData . dice , workerData . sides ) ; parentPort . postMessage ( stat ) ; }
اینکه آیا این عملی است یا نه، بحث دیگری است. من توصیه می کنم اسکریپت های اصلی و کارگر را تقسیم کنید مگر اینکه از ماژول های یکسان استفاده کنند.
به اشتراک گذاری داده های موضوعی
می توانید با استفاده از یک شی SharedArrayBuffer که نشان دهنده داده های باینری خام با طول ثابت است، داده ها را بین رشته ها به اشتراک بگذارید. رشته اصلی زیر 100 عنصر عددی را از 0 تا 99 تعریف می کند که برای یک کارگر ارسال می شود:
import { Worker } from "node:worker_threads" ; const buffer = new SharedArrayBuffer ( 100 * Int32Array . BYTES_PER_ELEMENT ) , value = new Int32Array ( buffer ) ; value . forEach ( ( v , i ) => value [ i ] = i ) ; const worker = new Worker ( "./worker.js" ) ; worker . postMessage ( { value } ) ;
کارگر می تواند شی value
را دریافت کند:
import { parentPort } from 'node:worker_threads' ; parentPort . on ( "message" , value => { value [ 0 ] = 100 ; } ) ;
در این مرحله، نخ های اصلی یا کارگر می توانند عناصر موجود در آرایه value
را تغییر دهند و در هر دو تغییر می کند. این منجر به افزایش کارایی می شود زیرا سریال سازی داده ها وجود ندارد، اما:
شما فقط می توانید اعداد صحیح را به اشتراک بگذارید
ممکن است لازم باشد پیام هایی برای نشان دادن تغییر داده ها ارسال شود
این خطر وجود دارد که دو رشته همزمان مقدار مشابهی را تغییر دهند و همگام سازی را از دست بدهند
تعداد کمی از برنامهها به اشتراکگذاری دادههای پیچیده نیاز دارند، اما میتواند گزینه مناسبی در برنامههای با کارایی بالا مانند بازیها باشد.
Node.js Child Processes
پردازشهای کودک برنامه دیگری را راهاندازی میکنند (نه لزوماً جاوا اسکریپت)، دادهها را ارسال میکنند و نتیجه را معمولاً از طریق یک تماس پاسخ دریافت میکنند. آنها به روشی مشابه کارگران عمل می کنند، اما به طور کلی کارایی کمتری دارند و فرآیند فشرده تر هستند، زیرا به فرآیندهای خارج از Node.js وابسته هستند. همچنین ممکن است تفاوت ها و ناسازگاری های سیستم عامل وجود داشته باشد.
Node.js دارای سه نوع پردازش فرزند کلی با تغییرات همزمان و ناهمزمان است:
spawn
: فرآیند جدیدی را ایجاد می کند
exec
: یک پوسته ایجاد می کند و دستوری را در آن اجرا می کند
fork
: یک فرآیند Node.js جدید ایجاد می کند
تابع زیر از spawn
برای اجرای یک فرمان به صورت ناهمزمان با ارسال دستور، آرایه آرگومان ها و بازه زمانی استفاده می کند. وعده با یک شی حاوی ویژگی های complete
( true
یا false
)، یک code
(به طور کلی 0
برای موفقیت) و یک رشته result
یا رد می شود:
import { spawn } from 'node:child_process' ; function execute ( cmd , args = [ ] , timeout = 600000 ) { return new Promise ( ( resolve , reject ) => { try { const exec = spawn ( cmd , args , { timeout } ) ; let ret = '' ; exec . stdout . on ( 'data' , data => { ret += '\n' + data ; } ) ; exec . stderr . on ( 'data' , data => { ret += '\n' + data ; } ) ; exec . on ( 'close' , code => { resolve ( { complete : ! code , code , result : ret . trim ( ) } ) ; } ) ; } catch ( err ) { reject ( { complete : false , code : err . code , result : err . message } ) ; } } ) ; }
می توانید از آن برای اجرای یک فرمان سیستم عامل استفاده کنید، مانند فهرست کردن محتویات دایرکتوری کاری به عنوان رشته در macOS یا Linux:
const ls = await execute ( 'ls' , [ '-la' ] , 1000 ) ; console . log ( ls ) ;
Node.js Clustering
خوشههای Node.js به شما امکان میدهند که تعدادی از فرآیندهای یکسان را برای مدیریت کارآمدتر بارها انجام دهید. فرآیند اولیه اولیه می تواند خود را فورک کند – شاید یک بار برای هر CPU که توسط os.cpus()
برگردانده می شود. همچنین میتواند راهاندازی مجدد را هنگامی که یک نمونه با شکست مواجه میشود و پیامهای ارتباطی واسطهای بین فرآیندهای فورک شده مدیریت کند.
کتابخانه cluster
ویژگی ها و روش هایی از جمله:
.isPrimary
یا .isMaster
: true
برای فرآیند اصلی اصلی برمی گرداند
.fork()
: فرآیند کارگر کودک را ایجاد می کند
.isWorker
: true را برای فرآیندهای کارگر برمی گرداند
مثال زیر یک فرآیند وب سرور کارگر را برای هر CPU/هسته در دستگاه شروع می کند. یک ماشین 4 هسته ای چهار نمونه از وب سرور را ایجاد می کند، پس می تواند تا چهار برابر بار را تحمل کند. همچنین هر فرآیندی را که با شکست مواجه می شود مجدداً راه اندازی می کند، پس برنامه باید قوی تر باشد:
import cluster from 'node:cluster' ; import process from 'node:process' ; import { cpus } from 'node:os' ; import http from 'node:http' ; const cpus = cpus ( ) . length ; if ( cluster . isPrimary ) { console . log ( ` Started primary process: ${ process . pid } ` ) ; for ( let i = 0 ; i < cpus ; i ++ ) { cluster . fork ( ) ; } cluster . on ( 'exit' , ( worker , code , signal ) => { console . log ( ` worker ${ worker . process . pid } failed ` ) ; cluster . fork ( ) ; } ) ; } else { http . createServer ( ( req , res ) => { res . writeHead ( 200 ) ; res . end ( 'Hello!' ) ; } ) . listen ( 8080 ) ; console . log ( ` Started worker process: ${ process . pid } ` ) ; }
همه پردازش ها پورت 8080
را به اشتراک می گذارند و هر یک می تواند یک درخواست HTTP ورودی را مدیریت کند. گزارش هنگام اجرای برنامه ها چیزی شبیه به این را نشان می دهد:
$ node app . js Started primary process : 1001 Started worker process : 1002 Started worker process : 1003 Started worker process : 1004 Started worker process : 1005 ... etc ... worker 1002 failed Started worker process : 1006
تعداد کمی از توسعه دهندگان اقدام به خوشه بندی می کنند. مثال بالا ساده است و به خوبی کار می کند، اما با تلاش برای مدیریت خرابی ها، راه اندازی مجدد و پیام های بین فورک ها، کد می تواند به طور فزاینده ای پیچیده شود.
مدیران فرآیند
یک مدیر فرآیند Node.js می تواند به اجرای چندین نمونه از یک برنامه کاربردی Node.js بدون نیاز به نوشتن کد کلاستر کمک کند. شناخته شده ترین آنها PM2 است. دستور زیر یک نمونه از برنامه شما را برای هر CPU/هسته ای شروع می کند و هر کدام از آنها با شکست مواجه می شوند مجدداً راه اندازی می شود:
pm2 start ./app.js -i max
نمونههای برنامه در پسزمینه شروع میشوند، پس برای استفاده در سرور زنده ایدهآل است. می توانید با وارد کردن pm2 status
تحلیل کنید که کدام فرآیندها در حال اجرا هستند:
$ pm2 status ┌────┬──────┬───────────┬─────────┬─────────┬──────┬────────┐ │ id │ name │ namespace │ version │ mode │ pid │ uptime │ ├────┼──────┼───────────┼─────────┼─────────┼──────┼────────┤ │ 1 │ app │ default │ 1.0 .0 │ cluster │ 1001 │ 4D │ │ 2 │ app │ default │ 1.0 .0 │ cluster │ 1002 │ 4D │ └────┴──────┴───────────┴─────────┴─────────┴──────┴────────┘
PM2 همچنین می تواند برنامه های غیر Node.js را که به زبان های Deno، Bun، Python و غیره نوشته شده اند اجرا کند.
ارکستراسیون کانتینری
خوشه ها و مدیران فرآیند یک برنامه را به یک دستگاه خاص متصل می کنند. اگر سرور یا وابستگی سیستم عامل شما خراب شود، برنامه شما بدون در نظر گرفتن تعداد نمونه های در حال اجرا از کار می افتد.
کانتینرها مفهومی مشابه ماشین های مجازی هستند، اما به جای تقلید سخت افزار، از یک سیستم عامل تقلید می کنند. کانتینر یک بسته بندی سبک وزن در اطراف یک برنامه واحد با تمام سیستم عامل، کتابخانه و فایل های اجرایی لازم است. یک کانتینر میتواند شامل یک نمونه مجزا از Node.js و برنامه شما باشد، پس روی یک دستگاه یا در هزاران ماشین اجرا میشود.
ارکستراسیون کانتینر از حوصله این مقاله خارج است، پس باید نگاهی دقیق تر به Docker و Kubernetes بیندازید.
نتیجه
کارگران Node.js و روشهای چند رشتهای مشابه، عملکرد برنامه را بهبود میبخشند و با اجرای موازی کد، گلوگاهها را کاهش میدهند. آنها همچنین میتوانند با اجرای توابع خطرناک در رشتههای جداگانه و پایان دادن به آنها در زمانی که زمان پردازش از محدودیتهای خاص فراتر رفت، برنامهها را قویتر کنند.
کارگران هزینه بالایی دارند، پس ممکن است برای اطمینان از بهبود نتایج، آزمایشاتی لازم باشد. ممکن است برای کارهای سنگین I/O ناهمزمان به آنها نیاز نداشته باشید، و مدیریت فرآیند/کانتینر میتواند راه آسانتری برای مقیاسبندی برنامهها ارائه دهد.
ارسال نظر