สิ่งที่ควรรู้เกี่ยวกับ Coroutine — CoroutineContext, CoroutineScope, Job

Phisit Mongkolnitayakul
te<h @TDG
Published in
4 min readMay 19, 2020

บทความนี้เราจะมาทำความเข้าใจเกี่ยวกับ CoroutineContext , CoroutineScope, Job มีความเกี่ยวข้องกันยังไง ?, ปัญหาที่เราไม่อยากจะเจอ(แต่เจอบ่อยจัง…)สำหรับ developer ถ้าเกิด exception ขึ้นมาใน scope ของ coroutine เราจะจัดการยังไง สิ่งเหล่านี้อาจส่งผลให้การทำงานของโปรแกรมของเราทำงานผิดพลาด อีกทั้งยังส่งผลต่อ performance แน่ๆ หากเรามีจัดการไม่ถูกต้อง

Photo by Jonathan Formento on Unsplash

มาเริ่มกันจากสิ่งแรกที่เราต้องรู้เกี่ยวกับ coroutine ทำงานภายใต้ scope และในการสร้าง coroutine นั้นเราต้องสร้างผ่าน coroutine builders (launch, async, etc.) ทั้งหมดนี้ต่างก็เป็น extension functions ของCoroutineScopeทั้งนั้นแล้ว CoroutineScopeคืออะไร? CoroutineScope เป็น Interface class ซึ่งภายในนั้นมี property อยู่คือ CoroutineContext เป็น property ใช้เก็บ instance ของCoroutineContext ที่เก็บ set of elements ต่างๆ ที่ระบุการทำงานของ coroutine

ภาพแสดงความสัมพันธ์ระหว่าง CoroutineScope, CoroutineContext, CoroutineBuilders Credit of image https://www.codementor.io/blog/kotlin-coroutines-6n53p8cbn1 [0]
Elements of CoroutineContext [1]
  • 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 ยังไง

Credit of image https://medium.com/androiddevelopers/coroutines-first-things-first-e6187bf3bb21 [2]
  1. กรณี Cancel/fail หาก Job อยู่ที่ state active แล้วหากมีการเรียก job.cancel() หรือ มีการ failure จาก exception ต่างๆ job จะเปลี่ยน state เป็น isActive = false , isCancelled = true (ถ้ามี children ก็จะทำการ cancel children ก่อน)
  2. กรณี 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)
Credit of image https://medium.com/androiddevelopers/coroutines-first-things-first-e6187bf3bb21 [3] : result of ParentScope context

ในที่นี้เราก็จะรู้แล้วว่า 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
}
new parent coroutineContext and 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 ต่อไป

Example code ที่แสดงว่าให้เมื่อมี exception เกิดขึ้นจะส่งผลให้ #child 1 และ #child 2 ถูกยกเลิกทั้งหมด แถมด้วย state ของ job เกิดการ exception จาก #child 1 สามารถดู flow ได้จากภาพที่ [2]

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 ด้วยตัวเอง

ตัวอย่างการใช้งาน SupervisorJob()

ผลลัพท์ไม่เป็นอย่างที่คาด #เพราะ #child 1 ก็ยังคงมี parent context เป็น Job อยู่เหมือนเดิม อย่างที่ผมได้บอกไว้ในหัวข้อของ Job ว่า “ทุกครั้งที่เราสร้าง coroutine ใหม่ขึ้นมาจาก coroutine builder ไม่ว่าจะเป็น launch หรือ async เราจะได้ return เป็น instance ของ Job”

กรอบสีแดงหมายถึง parent Job ของแต่ละ coroutineScope ส่วนกรอบสีเขียวคือ new coroutineContext

ถ้าเราแก้ #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 ก็ได้เช่นกันครับ

ตัวอย่างการใช้ supervisorScope สร้าง sub-scope ระหว่าง parent

coroutineScope และ supervisorScope มีการทำงานที่เหมือนกันคือสร้าง sub-scope ระหว่าง parent ให้เรา (ต่างกันแค่ใช้ Job หรือ supervisorJob) ซึ่งทั้งสองเป็น suspend function โดยทำงานจะทำการรอ (โดยไม่มีการบล็อค thread ปัจจุบัน) จนกว่า children ภายใน scope จะทำงานสำเร็จจึงจะทำการเรียก function ถัดไปที่อยู่ใน scope เดียวกัน

หมายเหตุ : สำหรับ Async ก็จะมีความต่างกันเล็กน้อยแต่หลักการ inherite CoroutineContextต่างๆ เหมือนกันครับ

จบไปแล้วกับบทความนี้อาจจะรวบรัด ถ้าอธิบายหมดน่าจะยาวมากๆ ยังไงก็อยากให้คนที่อ่านลองทำโปรเจ็ค example ขึ้นมาแล้วลองทำตามดูครับ

Credits:

--

--