concurrency ကိုသေချာနားလည်ထားရဲ့လား
2023 တုန်းက Java ရယ် NodeJS ရယ် Kotlin ရယ်ကို သူတို့ရဲ့ concurrency model တွေအကြောင်း article တပုဒ်ရေးဖြစ်ခဲ့တယ်။ သေချာလေး ရှင်းပြထားပေမယ့် အခုပြန်ဖတ်ကြည့်တော့ တော်တော်လေး မကောင်းဘူးလို့ ခံစားရတယ်။ ဒါကြောင့် concurrency နဲ့ပတ်သက်ပြီး အခု 2025 မှာ ဒုတိယအကြိမ် ထပ်ပြောဖို့ အတွေးရလာတယ်။ ပုံမှန်အားဖြင့် concurrency implementation အကြောင်းပြောတဲ့အခါ Java က blocking, NodeJS ဆိုရင် non-blocking စသဖြင့် အလွယ်မှတ်ခိုင်းလေ့ရှိကြတယ်။ ဘယ်ဟာက ဘယ်ဟာထက် ပိုမြန်တယ် ပိုသာတယ်ဆိုတာတွေရောပေါ့။ ဒါပေမယ့် ဒီလိုအပေါ်ယံ ပြောလိုက်တာက လေ့လာကာစ junior တယောက်အတွက် ဒီ blocking, non-blocking တွေက language ဒါမှမဟုတ် runtime ရဲ့မွေးရာပါ ပြင်မရတဲ့အချက်ကြီးလို့ မှတ်သွားနိုင်တယ်။
ဒီတော့ concurrency နဲ့ပတ်သက်လာရင် language API ဘောင်ကိုကျော်ပြီး နားလည်ထားပြီလား။ concurrency တွေရဲ့ implementation အကြောင်းပြောရင် OS layer က epoll, kqueue, io_uring စတဲ့ event notification mechanism တွေကစရလိမ့်မယ်။ ဒီ OS native construct တွေကိုသုံးပြီး IO event တွေ ဥပမာ ဘယ် socket ကတော့ data ရေးဖို့ ဒါမှမဟုတ် ဖတ်ဖို့ ready ဖြစ်ပြီလဲဆိုတာမျိုးကို thread တခုတည်းကနေ event တွေအများကြီးကို တပြိုင်နက်တည်း စောင့်ကြည့်လို့ရတဲ့အတွက် application တွေကို busy loop မပါပဲ resource နည်းနည်းနဲ့ efficient ဖြစ်အောင်ရေးလို့ရတယ်။
ကိုယ့် application က listener port တခု (ဥပမာ port 80) မှာ client connection တွေလက်ခံပြီး socket ဆောက်ပေးဖို့ thread တခုရှိတယ် ဆိုပါတော့။ scheduler thread ပေါ့။ လက်ခံလိုက်တဲ့ socket တွေဆီ ရောက်လာမယ့် connection တွေကို serve ဖို့ worker thread တွေအတွက်က thread pool တခုသပ်သပ်ထားမယ်။ တကယ်လို့ socket တခုကို thread တခုနှုန်းနဲ့ spawn မယ်ဆိုရင် socket တခုက TCP connection တခုဖြစ်တဲ့အတွက် client ၁၀ ယောက်ဆီက TCP connection ၁၀ ခုကို serve ချင်ရင် thread လည်း ၁၀ ခုလိုလိမ့်မယ်။ ဒါပေမယ့် CPU core က ၄ ခုပဲရှိရင်တော့ CPU ပေါ်မှာ တပြိုင်တည်း အလုပ်လုပ်လို့ရတဲ့ request က 4 ခုပဲပေါ့။ kernel ရဲ့သဘောက တကယ်လို့ အဲဒီ request တွေက blocking syscall တွေဖြစ်တဲ့ တခြား backend ဒါမှမဟုတ် database ကိုလှမ်းဆက်သွယ်တာ ဒါမှမဟုတ် file တွေရေးတာ ဖတ်တာစတဲ့ IO bound task တွေဆိုရင်တော့ အဲဒီ thread ကို CPU run queue ကနေဖယ်ပြီး တခြား runnable thread တခုကို ပေး run လိုက်တယ်။ ဒီတော့ စောစောက thread ၁၀ ခုက CPU core တွေကို အပြည့်အဝ အသုံးချနိုင်သွားတယ်။ scheduler thread ကတော့ throughput ရှိရင်ရှိသလောက် core တခုမှာ အမြဲတမ်း busy ဖြစ်နေမယ်။ ဒါက Java ရဲ့ servlet container တွေရဲ့ idea ပဲ။
ဒီပုံစံက ရိုးရှင်းပေမယ့် efficient ဖြစ်လားဆိုရင် မဖြစ်ဘူး။ thread တခုကနေ နောက်တခု ချိန်းရတာက process တခုကိုရပ်ပြီး နောက် process တခုကို အလှည့်ပေးရတာလောက်တော့ မဆိုးပေမယ့် penalty ရှိတာပဲ။ ဒီလို အလှည့်ပေးတာကို context switching လုပ်တယ်လို့ခေါ်တယ်။ process တခုတည်းက thread တွေက virtual memory space (heap, global) နဲ့ file descriptor စသဖြင့်တွေကို shared ပြီးသားဖြစ်လို့ context switching လုပ်တဲ့အခါ ရှေ့ thread အတွက် CPU register နဲ့ stack pointer တွေသိမ်းဖို့နဲ့ နောက် thread အတွက်သူ့ context ကိုပြန် prepare လုပ်ပေးဖို့ပဲလိုအပ်တယ်။ တကယ်လို့ request တွေက CPU bound တွေချည်းဖြစ်နေမယ်ဆိုရင် 4-core server တခုမှာ connection အသစ်တွေကို ဆက်တိုက်လက်ခံနေရင်းက ကျန်တဲ့ core 3 ခုကို request တွေ serve ဖို့ဆက်တိုက် တပြိုင်တည်းသုံးလို့ရတဲ့အတွက် ဒီ model ကတော်တော်လေးကောင်းတယ်။ ဒါပေမယ့် request တွေက IO bound တွေချည်း ဖြစ်နေခဲ့ရင်တော့ context switching အတွက်နဲ့ resource တွေအလကားဖြုန်းနေရလိမ့်မယ်။ နောက်ပြီး connection တခုကို serve ဖို့ thread တခုသုံးထားတဲ့ model ကလည်း resource expensive ဖြစ်တဲ့အပြင် client တွေက connection reuse မလုပ်ရင် ပိုဆိုးလိမ့်မယ်။ ဒီတော့ ဒါကို optimize လုပ်ဖို့ idea 2 ခုရှိတယ်။
ပထမတခုက scheduler thread ကိုအရင်တိုင်း ဆက်ထားမယ်။ ဒါပေမယ့် worker thread တွေက IO bound မယ့် task တွေကို မစောင့်တော့ပဲ epoll လို OS construct သုံးပြီး IO result ကို register လိုက်တယ်။ ဒီတော့ worker thread က ချက်ချင်း free ဖြစ်သွားပြီး နောက်ထပ် connection တခုကို ထပ် serve လို့ရသွားတယ်။ အချိန်တခုရောက်လို့ IO ready ဖြစ်ပြီဆိုတော့မှ အဲဒီ task အတွက်သိမ်းထားတဲ့ local variable တွေ stack frame တွေကို scheduler ကလွတ်နေတဲ့ နောက် thread တခုပေါ်မှာ prepare လုပ်ပေးပြီး task ကိုဆက် run သွားနိုင်တာမလို့ blocking thread ပုံစံနဲ့စာရင် အများကြီးပို efficient ဖြစ်သွားတယ်။ ဒါပေမယ့် အားနည်းချက်က CPU bound task တွေအတွက် thread တွေဆက်လိုနေတုန်းပဲ။ ဒီပုံစံက async servlet တွေရဲ့ idea ဖြစ်တယ်။
ဒုတိယပုံစံကကျ scheduling ရော execution ရောကို thread တခုတည်းမှာပဲ လုပ်မယ်။ thread တခုတည်းမှာ ဖြစ်တဲ့အတွက် one-thread-per-connection တုန်းကလို context switching လုပ်ဖို့မလိုတော့ဘူး။ ဒါပေမယ့် individual task တွေကို logical task တခုတည်းဖြစ်အောင် scheduler ကတာဝန်ယူဖို့လိုလာမယ်။ တနည်းပြောရရင် context switching ကို kernel space ကနေ user space ကိုတင်လိုက်တဲ့သဘောပဲ။ ဒီ model က IO bound task တွေအတွက်ဆိုရင် အတော်လေး effcient ဖြစ်သွားတယ်။ task တွေက IO စောင့်ရပြီဆိုတာနဲ့ scheduler ရဲ့အလှည့်ပြန်ရောက်လာပြီး နောက်ထပ် request တခုကို serve မလား။ ရှေ့မှာစောင့်နေတဲ့ task တခုပြီးပြီလားဆိုတာ ဆုံးဖြတ်ခွင့်ရတယ်။ တခုပဲ။ request တခုက CPU bound ဖြစ်နေရင်တော့ နောက်ထပ် အသစ်ဝင်လာတဲ့ request တွေကို main thread ကမ poll နိုင်တော့တဲ့အတွက် libuv queue ထဲမှာပဲ request တွေပုံလာပြီး client တွေအတွက် block သွားလိမ့်မယ်။ ဒါကြောင့် CPU bound မယ်ကျိန်းသေရင် worker thread တခုဆီ dispatch လုပ်နိုင်အောင် ရံထားသင့်တယ်။ NodeJS က ဒီ model ကိုသုံးတယ်။
ဒီတော့ application ရဲ့ nature က IO bound များရင် resource efficiency ဖြစ်မယ့် single threaded ကိုရွေး၊ CPU bound မယ်ဆိုရင် worker thread ကို non blocking နဲ့ async IO သွားဖို့ရွေးလို့ အလွယ်ပြောလို့ရပေမယ့် လက်တွေ့မှာ ဒီ model ၂ ခုက developer တွေအတွက် ကြပ်ကြပ်သပ်သပ် ရွေးချယ်စရာ trade off ဖြစ်နေတယ်။ တစ်အချက် workload တွေက IO bound နဲ့ CPU bound ရောနေတာ၊ နှစ်အချက် context switching ကိုခုထက် ပိုလျှော့ချချင်တာနဲ့ နံပါတ်သုံး OS threading က resource expensive ဖြစ်တာစတဲ့အချက်တွေကြောင့် ဒီထက်ပို performant ဖြစ် efficient ဖြစ်မယ့် နည်းတွေကို ထပ်စဥ်းစားလာကြတယ်။ အဲဒီထဲမှာမှ popular အဖြစ်ဆုံး idea က task တွေကို လိုအပ်ရင် pause/park လို့ရ၊ နောက်အချိန်တခုမှ ဆက် run/resume ခိုင်းလို့ရတဲ့ execution unit တွေအဖြစ် ခွဲထုတ်လိုက်ဖို့ဖြစ်တယ်။ ဒါဆိုရင် task နဲ့ thread ဟာ n:n mapping ဖြစ်ဖို့ မလိုတော့ပဲ n:m အဖြစ် ရှိသမျှ task တွေအကုန်လုံးကို thread လက်တဆုပ်စာမှာပဲခွဲပြီး execute လုပ်လို့ရသွားမယ်။ ဒါကို multiplexing လုပ်တယ်လို့ခေါ်တယ်။ ဒီ model မှာလည်း ရှေ့က ၂ ခုလိုပဲ context switching ကို user space မှာလုပ်ဖို့လိုမယ်။ သူ့ကိုလည်း ပုံစံ ၂ မျိုးထပ်ခွဲပြီး တွေ့ရတယ်။
ပထမတခုက user-space context switching အပြင် scheduling ရော orchestrating ကိုပါ runtime component တခုက တာဝန်ယူပြီး လုပ်ပေးတာ။ ဥပမာ ဘယ် task က IO စောင့်နေပြီး ဘယ် task ကတော့ CPU ဆက်လိုသေးတဲ့ runnable လဲစသဖြင့် ခွဲခြားမှတ်ထားတာ၊ ဘယ် task တွေကိုဘယ်အချိန်မှာ ခဏရပ်ပြီး တခြား task တခုကို အလှည့်ပေးမယ်စသဖြင့် ဆုံးဖြတ်တာတွေကို lower level တခုကနေ runtime က manage လုပ်ပေးတယ်။ ဒီတော့ application က blocking syscall တွေချည်း သီးသန့်သုံးလို့ရတယ်။ ဘယ်နားမှာ တခြား task အတွက် အလှည့်ပေးမယ်ဆိုတာမျိုးကို developer က mental bookkeeping လုပ်စရာ မလိုဘူး။ task တခုက IO bound သွားရင် runtime ကသူ့ကို runnable queue ထဲက ဖြုတ်ထားမယ်။ CPU bound ဆိုရင်တော့ safe checkpoint တွေမှာရပ်ပြီး လိုအပ်ရင် တခြား task တခုကို အလှည့်ပေးမယ်။
အဓိကက concurrency ကို first class လို့တွေးလိုက်တာပဲ။ request တခုကို execution unit တခုနှုန်းနဲ့ serve သွားလို့ရတယ်။ တကယ်လို့ အဲဒီ request ထဲမှာ background task တွေ ဒါမှမဟုတ် IO bound task တွေလုပ်ဖို့လိုအပ်ရင် task တခုကို execution unit တခုစီနဲ့ ထပ် run သွားရုံပဲ။ ဒီ model ရဲ့ အားသာချက်က အခုလိုပုံစံကိုမှ OS thread တွေနဲ့သာဆိုရင် context switching အတွက် penalty အတော်ကြီးမှာဖြစ်သလို threading အတွက်လည်း resource အတော်သုံးရလိမ့်မယ်။ နောက်ပြီး thread တခုက CPU ကို monopolize လုပ်နေတဲ့ အခြေအနေမှာလည်း application ကဘာမှကြားဝင် ဖြေရှင်းပေးလို့ရမှာ မဟုတ်ဘူး။ အားနည်းချက်က application က runtime ကိုကျော်ပြီး ဆုံးဖြတ်လို့မရတာရယ်၊ runtime ရဲ့ကြားဝင်တဲ့ orchestrating overhead အတွက်ကိုလည်း CPU cycle တွေသုံးရတာရယ် နှစ်ချက်။ ဒါပေမယ့် ဒီ model က တော်တော်လေး developer friendly ဖြစ်ပြီး Golang ရဲ့ implementation ဖြစ်တယ်။ ဒီ model ကိုနောက်ထပ် join လာတာက Java ရဲ့ virtual thread တွေ။ သူတို့နှစ်ခုက stack's nature နဲ့ preemption မှာကွာပေမယ့် conceptual အခြေခံချင်းက အတူတူပဲ။
ဒုတိယပုံစံကကျ context switching အတွက် လိုအပ်တဲ့ context က execution unit ရဲ့ state အဖြစ်ရှိတယ်။ ဒါပေမယ့် runtime မပါဘူး။ runtime မပါတော့တဲ့အတွက် developer က scheduling ကိုကိုယ်တိုင်လုပ်နိုင်တယ်။ orchestration အတွက်လည်း developer မှာ control အပြည့်ရှိသွားတယ်။ ဥပမာ task တခုက CPU bound မယ်လို့ထင်ရင် နောက် execution unit တခုမှာ သီးသန့်လုပ်စေပြီး လက်ရှိ execution unit ကို block မဖြစ်အောင် ထားလို့ရတယ်။ တကယ်လို့ IO bound မယ်လို့ထင်ရင်လည်း နောက် execution unit တခုမှာ သီးသန့်လုပ်စေပြီး လိုအပ်ရင် တခြား task ကိုအလှည့်ပေးဖို့ ပြောလို့ရတယ်။ ဒီပုံစံကို Rust နဲ့ Kotlin မှာသုံးထားတယ်။ ကွာသွားတဲ့အချက်က သူတို့တခုချင်းစီရဲ့ အသုံးအများဆုံး runtime library implementation တွေမှာ Rust ရဲ့ tokio က primitive ပိုဆန်ပြီး Kotlin ရဲ့ coroutine ကတော့ high level ပိုဆန်သွားတယ်။
mobile app တခုဆိုပါတော့။ user ကစာတလုံးရိုက်တိုင်း အဲဒီစာလုံးကို backend ဆီပို့ပြီး autocomplete feature အတွက် suggestion တောင်းမယ်ဆိုပါတော့။ တကယ်လို့ အဲဒီ network API call ကို UI thread (main thread) ထဲမှာလုပ်မယ်ဆိုရင် API result ပြန်ရမလာမချင်း user အနေနဲ့ နောက်ထပ်စာလုံးတွေ ဆက်ရိုက်လို့ရတော့မှာ မဟုတ်ဘူး။ ဘာလို့လဲဆိုရင် UI thread က IO block နေတဲ့အတွက် OS ဆီကနေ UI event တွေမ poll နိုင်တော့လို့ပါပဲ။ user ရဲ့အမြင်မှာတော့ application က ဘာမှဆက်နှိပ်လို့မရပဲ hang နေတယ်လို့ပဲ မြင်ရလိမ့်မယ်။ တကယ်လို့သာ အဲဒီ API call ကအတိုင်းအတာတခုထက် ပိုကြာသွားခဲ့ရင် OS runtime က application ကို ANR (application not responding) flagged လိုက်လိမ့်မယ်။
ဒီပြဿနာကို ဖြေရှင်းတဲ့နည်းက developer အနေနဲ့ IO block မယ့် point မှာ cooperative signal ချန်ထားရပါမယ်။ ဒါမှသာ scheduler က detach လုပ်ရမှန်းသိမယ်။ Android ရဲ့ kotlinx coroutine API ကဒါကို အပြည့်အဝ support သလို iOS ရဲ့ swift async/await ကလည်း ဒီသဘောပါပဲ။ တကယ်လို့ ကိုယ့် task က CPU bound မယ့် task (ဥပမာ photo editing ရဲ့ pixel နဲ့ matrix manipulation လိုမျိုး task) ဆိုရင် UI thread ကနေ detach ပြီးတခြား thread တခုမှာ run ပေးဖို့ scheduler ကိုပြောဖို့လိုတယ်။ ဒါမှ CPU bound task ကလွတ်နေတဲ့ နောက် core တခုမှာ ဆက်အလုပ်လုပ်ပြီး UI thread ကြီး block မသွားမှာဖြစ်တယ်။ ဒီတော့ အချုပ်ပြောရရင် concurrency ဆိုတာ language ထဲပါတဲ့ async တွေ await တွေ new Thread(...) တွေမဟုတ်ပါဘူး။ concurrency ဆိုတာ runtime ရယ် code ရယ် kernel ရယ် စနစ်တကျ ပူးပေါင်းအလုပ်လုပ်ကြတဲ့ process တခုလို့ဆိုနိုင်တယ်။ process ထဲက အလွှာတခုချင်းစီက ဘာတွေလုပ်နိုင်တယ်၊ ဘယ် implementation ကဖြင့်ဘယ်လို limitation တွေရှိတယ်၊ ဘယ်လို အားသာချက်တွေရှိတယ်ဆိုတာကို ကောင်းကောင်းနားလည်ထားမယ်ဆိုရင် concurrent application တွေရေးနိုင်ဖို့ debug နိုင်ဖို့အတွက် ကျစ်လျစ်တဲ့ mental model တခု ကျိန်းသေပေါက်ရသွားပါလိမ့်မယ်။