สิ่งที่ควรรู้เกี่ยวกับ Coroutine — CoroutineContext, CoroutineScope, Job
บทความนี้เราจะมาทำความเข้าใจเกี่ยวกับ CoroutineContext , CoroutineScope, Job มีความเกี่ยวข้องกันยังไง ?, ปัญหาที่เราไม่อยากจะเจอ(แต่เจอบ่อยจัง…)สำหรับ developer ถ้าเกิด exception ขึ้นมาใน scope ของ coroutine เราจะจัดการยังไง สิ่งเหล่านี้อาจส่งผลให้การทำงานของโปรแกรมของเราทำงานผิดพลาด อีกทั้งยังส่งผลต่อ performance แน่ๆ หากเรามีจัดการไม่ถูกต้อง
มาเริ่มกันจากสิ่งแรกที่เราต้องรู้เกี่ยวกับ coroutine ทำงานภายใต้ scope และในการสร้าง coroutine นั้นเราต้องสร้างผ่าน coroutine builders (launch, async, etc.) ทั้งหมดนี้ต่างก็เป็น extension functions ของCoroutineScope
ทั้งนั้นแล้ว CoroutineScope
คืออะไร? CoroutineScope
เป็น Interface class ซึ่งภายในนั้นมี property อยู่คือ CoroutineContext เป็น property ใช้เก็บ instance ของCoroutineContext
ที่เก็บ set of elements ต่างๆ ที่ระบุการทำงานของ coroutine
- Job : ใช้สำหรับจัดการ lifecycle ของ coroutine
- CoroutineDispatcher : ใช้ระบุการทำงาน coroutine บน thread ต่างๆ
- Coroutine name : ใช้ระบุชื่อของ coroutine
- CoroutineExceptionHandler: ใช้ในการจัดการ exception ที่เกิดขึ้นก่อนที่จะถูกส่งต่อไปยัง thread uncaught exception handler
Job
ทุกครั้งที่เราสร้าง coroutine ใหม่ขึ้นมาจาก coroutine builder ไม่ว่าจะเป็น launch หรือ async เราจะได้ return เป็น instance ของ Job ใช้สำหรับการจัดการ lifecycle ของ coroutine (และมีผลต่อ children เนื่องจากการ cancel/failure จาก exception ต่างๆ) ในที่นี้เราสามารถเข้าถึง state ของมันผ่าน properties isActive , isCancelled, isCompleted
ภาพด้านล่างแสดงถึง lifecycle ของ Job ส่งผลต่อ state ยังไง
- กรณี Cancel/fail หาก Job อยู่ที่ state active แล้วหากมีการเรียก job.cancel() หรือ มีการ failure จาก exception ต่างๆ job จะเปลี่ยน state เป็น isActive = false , isCancelled = true (ถ้ามี children ก็จะทำการ cancel children ก่อน)
- กรณี Complete หาก Job ทำงานสำเร็จจะเปลี่ยน state เป็น isCancelled = false , isCompleted = true (ถ้ามี children ก็จะทำการรอจนกว่า Children จะทำงานเสร็จ)
CoroutineScope
เป็นตัวกลางให้เราสามารถสร้าง coroutine ใหม่ผ่าน coroutine builder (launch, async, etc)และใช้จัดการ lifecycle ของ coroutine ภายใต้ scope นั้นๆ อย่างเช่นใน android เราก็ต้องจัดการ lifecycle ของ coroutine ให้เป็นไปตาม lifecycle ของ activity, fragment , viewModel
Note : CoroutineScope เป็นส่วนที่สำคัญในการสร้างความสัมพันธ์ระหว่าง parent — children เพราะมันเป็นตัวกลางในการ inherite coroutineContext ผ่าน coroutine builder ไปยัง children (เราจะไปดูรายละเอียดในหัวข้อถัดไป)
การสร้าง Coroutine Scope ก็ประมาณนี้
// Job and Dispatcher are combined into a CoroutineContext which
// will be discussed shortly
val parentScope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// new coroutine
}
สำหรับ android มีตัวช่วยให้เราจัดการ scope ง่ายๆ ผ่าน Android KTX library ซึ่งมีการ provides scope ให้เราเรียกใช้ผ่าน viewModelScope, lifecycleScope
CoroutineContext
จากที่เราได้เห็นภาพ CoroutineContext แล้ว ภาพที่ [1] ถ้าให้เปรียบเทียบมันก็คือ environment หนึ่งที่ระบุว่า coroutine ของเราจะทำงานยังไงอย่างเช่น กำหนด thread ที่จะทำงาน, จัดการ exception ที่เกิดขึ้นยังไงเป็นต้น
coroutine ทุกตัวจะมี parent context ซึ่งจะถูกใช้เพื่อเริ่มต้นการทำงานของ coroutine ภายใน parent context จะมี elements ที่ต่างกันออกไปซึ่งจะขึ้นอยู่กับปัจจัยข้างล่างนี้
Parent context = Defaults + inherited CoroutineContext
+ arguments
- Defaults คือ บาง element มีค่า default ยกตัวอย่าง Dispatcher = Dispatchers.Default และ Coroutine name = “coroutine”
- Inherited
CoroutineContext
elements เหล่านี้อาจถูก inherite มาจากCoroutineScope
หรือ coroutine ที่สร้างมันขึ้นมา - arguments คือ arguments ที่ถูก pass มาจาก coroutine builder โดยค่านี้จะทำการ override ค่าที่ถูก inherited มาจาก Inherited context
Note : CoroutineContext สามารถ combine ได้ด้วย operator + ผลลัพท์จะได้เหมือนการ override ยกตัวอย่าง CoroutineScope(Dispatchers.Main + Dispatchers.IO) ผลลัพท์ CoroutineScope(Dispatchers.IO)
Example
val parentScope = CoroutineScope(Job() + Dispatchers.Main + coroutineExceptionHandler)
ในที่นี้เราก็จะรู้แล้วว่า context ของ parent scope มีรูปร่างหน้าตาเป็นยังไง
ถ้าเราเอา parentScope มาสร้าง coroutine ใหม่ล่ะ?
new coroutine context = parent CoroutineContext
+ Job()
val parentScope = CoroutineScope(Job() + Dispatchers.Main + coroutineExceptionHandler)
val job = parentScope.launch(Dispatcher.IO){
// new coroutineContext
}
(กรอบแดงคือ parent context ที่จะถูกใช้เพื่อเริ่มต้นการทำงานของ coroutine นี้ซึ่งเราไม่สามารถเข้าถึง property นี้ได้ อ้าาวแล้วจะเกริ่นมาทำไมเนี่ย.. ^_^) parent context จะถูกอ้างอิงเมื่อมีการ cancel หรือเมื่อเกิด exception ขึ้นภายใน coroutine หรือ scope นั้นๆ ส่งผลให้ coroutine ที่เป็น children ถูกยกเลิกการทำงานทั้งหมด (code ตัวอย่างอยู่ด้านล่าง)
กรอบสีเขียว คือ coroutineContext ที่จะถูกนำไปใช้งานภายใน scope นั้นๆ เช่นถูก inherite ไปยัง suspend funtion , หรือ coroutine builder เพื่อที่จะนำไป inherite ให้กับ children ภายใต้ scope ต่อไป
Note: scope หรือ Job ที่ถูก cancel ไม่สามารถ start coroutine ได้อีก
ทำไม exception เกิดขึ้นที่ #child 1 ถึงทำให้ #child 2 ถูกยกเลิกด้วยล่ะ ?
เพราะ #child 1 มี parent context เป็น Job
ทันที เมื่อมี exception เกิดขึ้นใน childrent exception นั้นจะถูกส่งต่อไปยัง parent Job ทันทียกเว้น CancellationException. parent Job จะทำการ cancel children ทั้งหมดก่อนที่จะ cancel ตัวเอง… แล้วถ้าเกิดเราใช้ท่านี้ในการเรียกใช้ Service API ต่างๆ ละแน่นอนถ้าเกิด exception เหมือน case นี้การ call Service API ต่างๆ จะถูกยกเลิกไปด้วย
ถ้าเราไม่อยากให้ child 2 ถูกยกเลิกด้วยล่ะ ? เราจะทำยังไงดี
พูดแล้วน้ำตาจะไหลล แต่เรามีทางออกครับ SupervisorJob
หลักการมีอยู่ว่า SupervisorJob
จะไม่ cancel children อื่นๆ หากมี child ใด child เกิด exception อีกทั้งตัวมันเองจะไม่ถูก cancel ด้วยและไม่ส่งต่อ exception ไปยัง parent. ประมาณว่าให้แต่ละ child จัดการ exception ด้วยตัวเอง
ผลลัพท์ไม่เป็นอย่างที่คาด #เพราะ #child 1 ก็ยังคงมี parent context เป็น Job
อยู่เหมือนเดิม อย่างที่ผมได้บอกไว้ในหัวข้อของ Job ว่า “ทุกครั้งที่เราสร้าง coroutine ใหม่ขึ้นมาจาก coroutine builder ไม่ว่าจะเป็น launch หรือ async เราจะได้ return เป็น instance ของ Job”
ถ้าเราแก้ #child 1 ให้เป็น launch(supervisorJob())
ล่ะ
ผลลัพท์ก็จะได้แบบที่เราต้องการแต่จะทำให้ #child 1 ไม่เป็น child ของ parentScope.launch(SupervisorJob() + Dispatchers.IO) ทำให้เราไม่สามารถ cancel #child 1 ได้ ผ่าน parentScope.cancel() หรือ job.cancel()
ในกรณี นี้มีวิธีการให้เลือกใช้หลายแบบเช่นให้ #child 1 และ #child 2
Start ด้วย parentScope หรือจะเลือกใช้ supervisorScope ก็ได้เช่นกันครับ
coroutineScope และ supervisorScope มีการทำงานที่เหมือนกันคือสร้าง sub-scope ระหว่าง parent ให้เรา (ต่างกันแค่ใช้ Job หรือ supervisorJob) ซึ่งทั้งสองเป็น suspend function โดยทำงานจะทำการรอ (โดยไม่มีการบล็อค thread ปัจจุบัน) จนกว่า children ภายใน scope จะทำงานสำเร็จจึงจะทำการเรียก function ถัดไปที่อยู่ใน scope เดียวกัน
หมายเหตุ : สำหรับ Async ก็จะมีความต่างกันเล็กน้อยแต่หลักการ inherite CoroutineContext
ต่างๆ เหมือนกันครับ
จบไปแล้วกับบทความนี้อาจจะรวบรัด ถ้าอธิบายหมดน่าจะยาวมากๆ ยังไงก็อยากให้คนที่อ่านลองทำโปรเจ็ค example ขึ้นมาแล้วลองทำตามดูครับ
Credits: