Kotlin in Action 2판 15장 구조화된 동시성
Kotlin in Action 2판 15장 구조화된 동시성에 대한 내용입니다.
Kotlin in Action 2판 15장 구조화된 동시성
15장 구조화된 동시성
15.1 코루틴 스코프가 코루틴 간의 구조를 확립한다
- 구조화된 동시성은 코루틴의 부모-자식 계층을 자동으로 만들어 실행과 취소를 체계적으로 관리 가능한 매커니즘
- 이 구조 덕분에 리소스 누수, 방치된 코루틴, 불필요한 작업을 효과적으로 예방할 수 있으며, 코루틴 컨텍스트와 구조화된 동시성이 긴밀하게 연결되어 있어, 오류나 취소의 경우 각 코루틴을 일일이 추적하지 않아도 안전하게 전파되는 구조를 갖추고 있음
- 따라서 애플리케이션 전반에 구조화된 동시성을 사용하면 계획보다 오래 실행되거나 잊혀진 ‘제멋대로인’ 코루틴은 발생하지 않음
15.1.1 코루틴 스코프 생성: coroutineScope 함수
- 코루틴 빌더를 사용해 새로운 코루틴을 만들면 자체적인 CoroutineScope가 생성됨
- 이를 통해 새로운 코루틴을 만들지 않고도 코루틴 스코프를 그룹화할 수 있음
- coroutineScope 함수는 일시 중단 함수로, 새로운 코루틴 스코프를 생성하고 해당 영역 안의 모든 자식 코루틴이 완료될 때까지 기다림
- 그렇기에 아래 예제와 같이 coroutineScope 동시적 작업 분해(여러 코루틴을 활용해 계산을 수행)에 주로 사용됨
1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun generateValue(): Int {
delay(500.milliseconds)
return Random.nextInt(0, 10)
}
suspend fun computeSum() {
log("Computing a sum...")
val sum = coroutineScope {
val a = async { generateValue() }
val b = async { generateValue() }
a.await() + b.await()
}
log("Sum is $sum")
}
fun main() = runBlocking { computeSum() }
1
2
[main] Computing a sum...
[main] Sum is 10
- computeSum 함수 실행 시 “Computing a sum…” 로그 출력
- coroutineScope 내에서 async로 두 개의 자식 코루틴을 만들고 각각 500ms 후 랜덤값을 반환
- a.await(), b.await()로 두 값이 모두 준비될 때까지 기다림
- coroutineScope 는 결과를 반환하기 전에 모든 자식 코루틴이 완료되길 기다림
15.1.2 코루틴 스코프를 컴포넌트와 연관시키기: CoroutineScope
- coroutineScope 함수가 작업 분해에 사용되는 반면, 생명주기를 정의하고 코루틴의 시작과 종료를 관리하는 클래스를 만들고 싶을 때는 CoroutineScope 생성자를 사용해 새로운 독자적인 코루틴 스코프를 생성할 수 있음
- CoroutineScope 생성자는 하나의 파라미터를 받는데, 이는 해당 코루틴 스코프와 연관된 코루틴 컨텍스트로 예를 들어 Dispatcher를 지정할 수 있음
- 기본적으로 CoroutineScope를 디스패처만으로 호출하면 새로운 Job이 자동으로 생성지만 실무에서는 CoroutineScope와 함께 SupervisorJob을 사용하는 것이 좋음(왜 좋은지 궁금)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ComponentWithScope(dispatcher: CoroutineDispatcher = Dispatchers.Default) {
private val scope = CoroutineScope(dispatcher + SupervisorJob())
fun start() {
log("Starting!")
scope.launch {
while (true) {
delay(500.milliseconds)
log("Component working!")
}
}
scope.launch {
log("Doing a one-off task...")
delay(500.milliseconds)
log("Task done!")
}
}
fun stop() {
log("Stopping!")
scope.cancel()
}
}
fun main() {
val c = ComponentWithScope()
c.start()
Thread.sleep(2000)
c.stop()
}
1
2
3
4
5
6
7
// 22 [main] Starting!
// 37 [DefaultDispatcher-worker-2 @coroutine#2] Doing a one-off task...
// 544 [DefaultDispatcher-worker-1 @coroutine#2] Task done!
// 544 [DefaultDispatcher-worker-2 @coroutine#1] Component working!
// 1050 [DefaultDispatcher-worker-1 @coroutine#1] Component working!
// 1555 [DefaultDispatcher-worker-1 @coroutine#1] Component working!
// 2039 [main] Stopping!
- start를 호출하면 두 개의 코루틴이 실행됨
- stop을 호출하면 scope.cancel()로 모든 코루틴이 종료됨
- 생명주기를 관리해야하는 컴포넌트를 다루는 프레임워크에서ㅡㄴ 내부적으로 CoroutineScope 함수를 많이 사용
함수 | 목적/용도 | 특징 및 차이점 |
---|---|---|
coroutineScope | 여러 작업(코루틴)을 동시성으로 실행·분해할 때 사용함 | - 모든 자식 코루틴이 완료될 때까지 기다림 (일시 중단 함수) - 결과값 계산 및 반환 가능 |
CoroutineScope | 코루틴을 클래스 생명주기 등과 연관시키는 스코프(영역) 생성에 사용함 | - 단순히 스코프만 생성하고, 추가 작업을 기다리지 않고 즉시 반환 - 생성한 스코프는 추후에 취소 가능(1.5.2절 참고) |
15.1.3 GlobalScope의 위험성
- GlobalScope는 구조화된 동시성 계층에 포함되지 않는 전역 코루틴 스코프임
- 전역 범위에서 시작된 코루틴은 자동으로 취소되지 않으며, 생명주기에 대한 개념도 없음
- 따라서 GlobalScope를 사용하면 리소스 누수가 발생하거나, 필요하지 않은 작업이 계속 실행되면서 시스템 자원이 낭비될 가능성이 큼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fun main() {
runBlocking {
GlobalScope.launch {
delay(1000.milliseconds)
launch {
delay(250.milliseconds)
log("Grandchild done")
}
log("Child 1 done!")
}
GlobalScope.launch {
delay(500.milliseconds)
log("Child 2 done!")
}
log("Parent done!")
}
}
// 28 [main @coroutine#1] Parent done!
- GlobalScope로 시작된 launch들은 runBlocking의 자식이 아니므로, 프로그램이 빠르게 종료되면 자식 코루틴이 끝나기도 전에 로그가 출력되지 않을 수 있음
- 따라서 코루틴 빌더나 coroutineScope 함수를 사용해 더 적합한 스코프(영역)에서 코루틴을 시작하는 것이 좋음
15.1.4 코루틴 컨텍스트와 구조화된 동시성
- 코루틴 컨텍스트는 구조화된 동시성과 밀접한 관련이 있으며, 코루틴 간의 부모-자식 계층을 따라 상속됨
- 자식 코루틴은 부모의 컨텍스트를 상속받고, 새로운 Job 객체가 생성되어 부모의 Job 객체의 자식이 됨
- 예제와 같이 코루틴 컨텍스트의 job, job.parent, job.children 속성으로 계층 구조를 확인할 수 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import kotlinx.coroutines.job
fun main() = runBlocking(CoroutineName("A")) {
log("A's job: ${coroutineContext.job}")
launch(CoroutineName("B")) {
log("B's job: ${coroutineContext.job}")
log("B's parent: ${coroutineContext.job.parent}")
}
log("A's children: ${coroutineContext.job.children.toList()}")
}
// 0 [main @A#1] A's job: "A#1":BlockingCoroutine{Active}@41
// 10 [main @A#1] A's children: "B#2":StandaloneCoroutine{Active}@24
// 11 [main @B#2] B's job: "B#2":StandaloneCoroutine{Active}@24
// 11 [main @B#2] B's parent: "A#1":BlockingCoroutine{Completing}@41
- 각 코루틴의 컨텍스트에서 job, parent, children 등 계층 관계를 확인할 수 있음
- 결론적으로 디스패처를 명시하지 않고 새로운 코루틴을 시작하면 Dispatchers.Default가 아니라, 부모 코루틴의 디스패처에서 실행된 다는 것을 알 수 있음
15.2 취소
- 코루틴의 취소는 코드가 모두 끝나기 전에 실행을 중단하는 것임
- 실제 애플리케이션에서 계산, 네트워크 등 여러 작업을 수행할 때 취소 기능은 필수임
- 또한 취소는 불필요한 작업과 리소스 낭비, 메모리 누수를 막고 오류 처리도 쉽게 해줌
- 예시로 들어 사용자가 화면을 떠나면(약간 애매한데 화면이 파괴되면으로 보는 것이 알맞음) 실행 중인 코루틴을 바로 중단해야 함
15.2.1 취소 촉발
- launch, async 등 코루틴 빌더가 반환하는 Job, Deferred 객체의 cancel() 메서드를 호출해 해당 코루틴의 취소를 촉발할 수 있음
- 코루틴 스코프의 컨텍스트에도 Job이 있으므로, 스코프 전체를 취소할 수도 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun main() {
runBlocking {
val launchedJob = launch {
log("I'm launched!")
delay(1000.milliseconds)
log("I'm done!")
}
val asyncDeferred = async {
log("I'm async")
delay(1000.milliseconds)
log("I'm done!")
}
delay(200.milliseconds)
launchedJob.cancel()
asyncDeferred.cancel()
}
}
1
2
[main @coroutine#2] Im launched!
[main @coroutine#3] Im async
- 200ms 후 두 코루틴 모두 취소되므로 “Im done!”은 출력되지 않음
- launch, async가 반환하는 객체를 통해 직접 취소를 관리함
15.2.2 시간 제한이 초과된 후 자동으로 취소 호출
- Kotlin 코루틴 라이브러리는 withTimeout, withTimeoutOrNull 함수를 제공하며, 함수들은 지정한 시간 내에 블록이 완료되지 않으면 코루틴을 자동으로 취소한다는 특징을 가지고 있음
- withTimeout은 TimeoutCancellationException 예외를 발생시키고, withTimeoutOrNull은 null을 반환함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
suspend fun calculateSomething(): Int {
delay(3.seconds)
return 2 + 2
}
fun main() = runBlocking {
val quickResult = withTimeoutOrNull(500.milliseconds) {
calculateSomething()
}
println(quickResult)
// null
val slowResult = withTimeoutOrNull(5.seconds) {
calculateSomething()
}
println(slowResult)
// 4
}
- 첫 호출은 500ms 내에 결과가 준비되지 않아 취소되고 null이 반환됨
- 두 번째 호출은 5초 내에 결과(4)를 정상 반환함
참고: withTimeout이 발생시키는 TimeoutCancellationException을 잡지 않으면 의도와 다르게 코루틴 전체가 종료될 수 있음
안전하게 처리하려면 withTimeoutOrNull을 쓰거나 try-catch로 TimeoutCancellationException에 대한 예외를 잡아야 함
15.2.3 취소는 모든 자식 코루틴에게 전파된다
- 코루틴 계층의 부모 Job이 취소되면 모든 자식 코루틴도 자동으로 취소됨
- 중첩된 launch가 여러 계층이라도, 최상위 코루틴의 cancel()만 호출하면 하위 모든 코루틴이 정리됨
- 즉 취소 하위 전파 매커니즘은 처음에 얘기한 제멋대로인 코루틴이 남지 않도록 할 수 있는 장치임
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun main() = runBlocking {
val job = launch {
launch {
launch {
launch {
log("I'm started")
delay(500.milliseconds)
log("I'm done!")
}
}
}
}
delay(200.milliseconds)
job.cancel()
}
// 0 [main @coroutine#5] I'm started
- job.cancel() 호출로 모든 하위 launch가 일괄적으로 취소됨
- “I’m done!” 로그는 출력되지 않음
15.2.4 취소된 코루틴은 특수한 지점에서 CancellationException을 던진다
- 취소 메커니즘은 일시 중단(suspension) 지점에서 CancellationException을 던짐
- delay, yield, withTimeout 같은 suspend 함수(중단점)는 일시 중단과 동시에 취소를 감지해서 예외를 던질 수 있음
1
2
3
4
5
6
coroutineScope {
log("A")
delay(500.milliseconds) // 이 지점에서 함수가 취소될 수 있음
log("B")
log("C")
}
delay(500.milliseconds)에서 취소가 발생하면 “A”까지만 출력되고 “B”, “C”는 출력되지 않음
- 예외를 잘못 처리하면 무한 반복 등 문제가 발생함 (아래 예제)
- 예외 핸들러에서 Exception을 catch할 때, CancellationException은 반드시 다시 던져야 정상적으로 취소가 전파됨 (아주 중요!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
suspend fun doWork() {
delay(500.milliseconds)
throw UnsupportedOperationException("Didn't work!")
}
fun main() {
runBlocking {
withTimeoutOrNull(2.seconds) {
while (true) {
try {
doWork()
} catch (e: Exception) {
println("Oops: ${e.message}")
}
}
}
}
}
- CancellationException도 catch되면 취소가 전파되지 않아 무한 루프가 발생하는 상황을 보여주는 예제
- Exception 대신 UnsupportedOperationException만 catch하거나, catch 블록에서 if (e is CancellationException) throw e를 추가해야 함
15.2.5 취소는 협력적이다
- 코틀린 코루틴은 기본적으로 협력적 취소(cooperative cancellation)를 지원함
- 즉, 코루틴 내에서 명시적으로 일시 중단(suspend) 지점을 만들지 않으면, 취소 요청이 와도 작업이 즉시 멈추지 않음
- 직접 작성한 suspend 함수 안에 delay, yield 등 취소 지점이 없으면 코루틴은 끝까지 실행됨(이전에 헷갈렸던 코루틴 로그 순서와 비슷한 결)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
suspend fun doCpuHeavyWork(): Int {
log("I'm doing work!")
var counter = 0
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() < startTime + 500) {
counter++
}
return counter
}
fun main() {
runBlocking {
val myJob = launch {
repeat(5) {
doCpuHeavyWork()
}
}
delay(600.milliseconds)
myJob.cancel()
}
}
30 [main @coroutine#2] I'm doing work!
535 [main @coroutine#2] I'm doing work!
1036 [main @coroutine#2] I'm doing work!
1537 [main @coroutine#2] I'm doing work!
2042 [main @coroutine#2] I'm doing work!
- doCpuHeavyWork 함수 내부에는 일시 중단 지점이 없으므로, 취소 요청이 와도 모든 반복이 끝날 때까지 코루틴이 멈추지 않음
- 취소는 suspend 함수 내부에 일시 중단 지점을 도입해야 제대로 취소됨
15.2.6 코루틴이 취소됐는지 확인
- 코루틴이 취소됐는지 확인할 때는
isActive
속성을 사용하거나, 직접 반복문 안에서 해당 값을 체크하여 탈출할 수 있음 ensureActive()
를 호출하면 코루틴이 비활성화 상태일 때 CancellationException을 던져 작업을 바로 멈춤
1
2
3
4
5
6
val myJob = launch {
repeat(5) {
doCpuHeavyWork()
if (!isActive) return@launch
}
}
또는
1
2
3
4
5
6
val myJob = launch {
repeat(5) {
doCpuHeavyWork()
ensureActive()
}
}
취소를 위한 유틸리티
isActive
속성: 현재 코루틴이 활성 상태인지 확인할 수 있음ensureActive()
함수: 취소된 경우 바로 CancellationException을 던짐yield()
함수: 일시 중단과 취소 지점 모두를 제공하며, 다른 코루틴에 실행 기회를 넘김
15.2.7 yield 함수
yield()
함수는 일시 중단 지점을 제공할 뿐 아니라, 같은 디스패처 내에서 다른 코루틴에게 실행 기회를 넘기는 역할도 함- 여러 코루틴이 같은 자원을 점유하고 있다면 yield를 통해 작업이 공평하게 나누어짐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import kotlinx.coroutines.*
suspend fun doCpuHeavyWork(): Int {
var counter = 0
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() < startTime + 500) {
counter++
yield()
}
return counter
}
fun main() {
runBlocking {
launch {
repeat(3) { doCpuHeavyWork() }
}
launch {
repeat(3) { doCpuHeavyWork() }
}
}
}
0 [main @coroutine#2] I'm doing work!
559 [main @coroutine#3] I'm doing work!
1062 [main @coroutine#2] I'm doing work!
1634 [main @coroutine#3] I'm doing work!
2208 [main @coroutine#2] I'm doing work!
2734 [main @coroutine#3] I'm doing work!
- 예제와 같이 yield를 사용하면 두 코루틴이 번갈아가며 작업을 처리함
- delay 없이 while 루프만 있을 때는 한 코루틴이 모두 끝나야 다음 코루틴이 시작됨
15.2.8 리소스를 얻을 때 취소를 염두에 두기
- 실제 코드에서는 데이터베이스 연결 등 외부 리소스를 코루틴에서 사용할 일이 많음
- 코루틴이 취소될 때 리소스 누수가 발생하지 않도록 finally 블록이나 use 함수를 사용해 반드시 정리해야 함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DatabaseConnection : AutoCloseable {
fun write(s: String) = println("writing $s!")
override fun close() = println("Closing!")
}
val dbTask = launch {
val db = DatabaseConnection()
try {
delay(500.milliseconds)
db.write("I love coroutines!")
} finally {
db.close()
}
}
또는
1
2
3
4
5
6
val dbTask = launch {
DatabaseConnection().use {
delay(500.milliseconds)
it.write("I love coroutines!")
}
}
- 코루틴이 취소되더라도 close()가 반드시 호출되어 리소스 누수를 막음
15.2.9 프레임워크가 여러분 대신 취소를 할 수 있다
- 실제 애플리케이션에서는 프레임워크가 코루틴 스코프와 취소를 관리하는 경우가 많음
- 예를 들어 안드로이드 ViewModel의 viewModelScope는 화면이 파괴되면 자동으로 취소됨
1
2
3
4
5
6
7
8
9
10
class MyViewModel : ViewModel() {
init {
viewModelScope.launch {
while (true) {
println("Tick!")
delay(1000.milliseconds)
}
}
}
}
- Ktor 서버 프레임워크에서도 요청 단위의 코루틴 스코프가 암시적으로 제공되며, 클라이언트 연결이 끊기면 스코프가 취소됨
1
2
3
4
5
6
7
8
9
routing {
get("/") {
launch {
println("I'm doing some background work!")
delay(5000.milliseconds)
println("I'm done")
}
}
}
- 클라이언트가 5초 안에 연결을 끊으면 “I’m done”이 출력되지 않음
- Application 스코프를 사용하면 앱 전체 생명주기에 맞춘 코루틴 실행도 가능함
This post is licensed under CC BY 4.0 by the author.