Kotlin - 코루틴
출처 : https://medium.com/@limgyumin/코틀린-코루틴의-기초-cac60d4d621b
https://medium.com/@limgyumin/코틀린-코루틴-제어-5132380dad7f
코틀린 코루틴의 기초
코틀린 의 코루틴은 비동기 프로그래밍을 처리할수 있는 좋은 방법입니다.
medium.com
CoroutineScope
- CoroutineScope 는 말 그대로 코루틴의 범위, 코루틴 블록을 묶음으로 제어할수 있는 단위입니다.
- GlobalScope 는 CoroutineScope 의 한 종류입니다. 미리 정의된 방식으로 프로그램 전반에 걸쳐 백그라운드 에서 동작합니다.
- CoroutineScope > GlobalScope, etc.
CoroutineContext
- CoroutineContext 는 코루틴을 어떻게 처리 할 것인지 에 대한 여러가지 정보의 집합입니다.
- CoroutineContext 의 주요 요소로는 Job 과 dispatcher 가 있습니다.
- DispatcherCoroutineContext 을 상속받아 어떤 스레드를 이용해서 어떻게 동작할것인지를 미리 정의해 두었습니다.Dispatchers.IO : 네트워크, 디스크 사용 할때 사용합니다. 파일 읽고, 쓰고, 소켓을 읽고, 쓰고 작업을 멈추는것에 최적화되어 있습니다.
- Dispatchers.Main : 안드로이드의 경우 UI 스레드를 사용합니다.
- Dispatchers.Default : CPU 사용량이 많은 작업에 사용합니다. 주 스레드에서 작업하기에는 너무 긴 작업 들에게 알맞습니다.
- Dispatcher 는 CoroutineContext 의 주요 요소 입니다.
코루틴은 이렇게 쓰면 됩니다
- 사용할 Dispatcher 를 결정하고
- Dispatcher 를 이용해서 CoroutineScope 만들고
- CoroutineScope 의 launch 또는 async 에 수행할 코드 블록을 넘기면 됩니다.
- launch 와 async 는 CoroutineScope 의 확장함수 이며, 넘겨 받은 코드 블록으로 코루틴을 만들고 실행해주는 코루틴 빌더 입니다.
val scope = CoroutineScope(Dispatchers.Main)
CoroutineScope(Dispatchers.Default).launch {
// 새로운 CoroutineScope 로 동작하는 백그라운드 작업
//CoroutineScope 를 새로 만들면 제어범위가 달라집니다.
}
scope.launch(Dispatchers.Default) {
// 기존 CoroutineScope 는 유지하되, 작업만 백그라운드로 처리
//launch 의 Dispatcher 를 변경하면,
//CoroutineScope 는 유지되면서 작업이 처리되는 스레드만 변경됩니다.
}
----------------------------------------------------------------------
제어범위가 다르면 다음과 같은 상황이 발생합니다.
다음 예시에서 내부 코루틴 블록은 멈추지 않습니다
val scope = CoroutineScope(Dispatchers.Main)
val job = scope.launch {
// ...
CoroutineScope(Dispatchers.Main).launch {
// 외부 코루틴 블록이 취소 되어도 끝까지 수행됨
}
// ...
}
// 외부 코루틴 블록을 취소
job.cancel()
...
기존 CoroutineScope 를 사용할지, 새로운 CoroutineScope 를 만들지 결정하는것은
코루틴 블록이 특정 상황에 어떻게 동작할 지를 결정하게 됩니다.
위 예시 에서는 외부 코루틴 블록 의 내부에서 새로운 CoroutineScope 를 만들었습니다.
이로서 외부 코루틴 블록 과 내부 코루틴 블록은 서로 제어범위가 달라집니다.
Job 객체의 cancel() 메서드는 자신이 해당하는 CoroutineScope 의 코루틴 블록을 취소시켜
멈출수 있지만, 내부 코루틴 블록은 다른 CoroutineScope 로 분리 되었기 때문에 멈출수 없습니다.
외부 코루틴 블록이 멈춰도, 내부 코루틴 블록은 끝까지 수행됩니다.
- 코루틴 블록을 제어하고 값을 전달받기 위해서는 launch 와 async 가 반환하는 Job, Deferred 객체를 사용합니다.
- runBlocking 은 내부 작업이 종료될때까지 일시 중지 됩니다.
4. launch() 함수로 시작된 코루틴 블록은 Job 객체를 반환합니다.
val job : Job = launch{ ...}
5. 반환받은 Job 객체로 코루틴 블록을 취소하거나, 다음 작업의 수행전 코루틴 블록이 완료 되기를 기다릴수 있습니다.
val job = launch {
var i = 0
while (i < 10) {
delay(500)
i++
}
}
job.join() // 완료 대기
job.cancel() // 취소
6. 여러개의 launch 코루틴 블록을 실행할 경우 각각의 Job 객체에 대해서 join() 함수로 코루틴 블록이 완료 될때까지 다음 코드 수행을 대기할수 있습니다.
val job1 : Job = launch {
var i = 0
while (i < 10) {
delay(500)
i++
}
}
val job2 = launch {
var i = 0
while (i < 10) {
delay(1000)
i++
}
}
job1.join()
job2.join()
7. 모든 Job 객체에 대해서 일일이 join() 함수를 호출하지 않고 joinAll() 함수를 이용하여 모든 launch 코루틴 블록이 완료 되기를 기다릴수도 있습니다.
joinAll(job1, job2)
8. 또는, 다음의 예시와 같이 첫번째 launch 코루틴 블록에서 반환받은 Job 객체를 두번째 launch() 함수의 인자로 사용하면, 동일한 Job 객체로 두개의 코루틴 블록을 모두 제어 할수 있습니다.
val job1 = launch {
var i = 0
while (i < 10) {
delay(500)
i++
}
}
// 위 블록 과 같은 job1 객체를 사용
launch(job1) {
var i = 0
while (i < 10) {
delay(1000)
i++
}
}
// 같은 job 객체를 사용하게 되면
// joinAll(job1, job2) 와 같다
job1.join()
9. launch() 함수로 정의된 코루틴 블록은 즉시 수행되며, 반환 받은 Job 객체는 해당 블록을 제어는 할수 있지만 코루틴 블록의 결과를 반환하지는 않습니다. 코루틴 블록의 결과 값을 반환받고 싶다면 async() 코루틴 블록을 생성합니다.
val deferred : Deferred<T> = async {
...
T // 결과값
}
이렇게 시작된 코루틴 블록은 Deferred 객체를 이용해 제어가 가능하며
동시에 코루틴 블록에서 계산된 결과값을 반환 받을수 있습니다.
val deferred : Deferred<String> = async {
var i = 0
while (i < 10) {
delay(500)
i++
}
"result"
}
val msg = deferred.await()
println(msg) // result 출력
10. 여러개의 async 코루틴 블록을 실행할 경우 각각의 Deferred 객체에 대해서 await() 함수로 코루틴 블록이 완료 될때까지 다음 코드 수행을 대기할수 있습니다. await() 함수는 코루틴 블록이 완료되면 결과를 반환합니다.
val deferred1 = async {
var i = 0
while (i < 10) {
delay(500)
i++
}
"result1"
}
val deferred2 = async {
var i = 0
while (i < 10) {
delay(1000)
i++
}
"result2"
}
val result1 = deferred1.await()
val result2 = deferred2.await()
println("$result1 , $result2") // result1 , result 2 출력
각각의 Deferred 객체에 대해서 await() 함수를 호출하지 않고
awaitAll() 함수를 이용하여 모든 async 코루틴 블록이 완료 되기를 기다릴수도 있습니다.
awaitAll(deferred1, deferred2)
11. 또는, 다음의 예시와 같이 첫번째 async 코루틴 블록에서 반환받은 Deferred 객체를 두번째 async() 함수의 인자로 사용하면, 동일한 Deferred 객체로 두개의 코루틴 블록을 모두 제어 할수 있습니다. 단, 여러개의 async 코루틴 블록에 같은 Deferred 객체를 사용할경우 await() 함수 호출시 전달되는 최종적인 결과값은 첫번째 async 코루틴 블록의 결과값 만을 전달한다는것에 주의해야 합니다.
val deferred = async {
var i = 0
while (i < 10) {
delay(500)
i++
}
"result1"
}
// 같은 Deferred 객체 사용
async(deferred) {
var i = 0
while (i < 10) {
delay(1000)
i++
}
"result2"
}
val msg = deferred.await()
println(msg) // 첫번째 블록 결과인 result1 출력
지연 실행
launch 코루틴 블록 과 async 코루틴 블록은 모두 처리 시점을 뒤로 미룰수 있습니다.
1. 각 코루틴 블록 함수의 start 인자에 CoroutineStart.LAZY 를 사용하면 해당 코루틴 블록은 지연 되어 실행됩니다.
val job = launch (start = CoroutineStart.LAZY) {
...
}
또는
val deferred = async (start = CoroutineStart.LAZY) {
...
}
2. launch 코루틴 블록을 지연 실행 시킬 경우 Job 클래스 의 start() 함수 를 호출하거나 join() 함수를 호출하는 시점에 launch 코드 블록이 수행됩니다.
job.start()
또는
job.join()
3. async 코루틴 블록을 지연 실행 시킬 경우 Deferred 클래스 의 start() 함수 를 호출하거나 await() 함수를 호출하는 시점에 async 코드 블록이 수행됩니다.
deferred.start()
또는
deferred.await()
4. 지연된 async 코루틴 블록 의 경우 start() 함수는 async 코루틴 블록을 실행 시키지만 블록의 수행 결과를 반환하지 않습니다. 또한 await() 함수와 다르게 코루틴 블록이 완료 되는것을 기다리지 않습니다.
println("start")
val deferred = async(start = CoroutineStart.LAZY) {
var i = 0
while (i < 5) {
delay(500)
println("lazy async $i")
i++
}
}
deferred.await()
println("end")
위 예시를 실행하면 결과는 다음과 같습니다.
await() 함수를 사용했기때문에 end 는 가장 마지막에 출력됩니다.
start
lazy async 0
lazy async 1
lazy async 2
lazy async 3
lazy async 4
end
하지만 deferred.start() 로 바꾸면 출력 결과는 다음과 같습니다.
end 는 start 가 출력 되자 마자 출력되고, 코루틴 블록이 수행됩니다.
start
end
lazy async 0
lazy async 1
lazy async 2
lazy async 3
lazy async 4
이 예제를 지연된 launch 코루틴 블록으로 바꾸어 실행해도
동일하게 start() 함수와 join() 함수 호출 의 결과가 다름을 알수 있습니다.
그러므로 start() 함수를 사용하여 지연 실행 하는 경우와
join() 또는 await() 함수를 사용하여 지연 실행 하는 경우,
해당 블록 이후의 코드 실행 순서에 차이가 발생할수 있음을 주의해야 합니다.
runBlocking()
runBlocking() 함수는 코드 블록이 작업을 완료 하기를 기다립니다.
runBlocking { ... }
1. launch() 함수로 시작된 블록은 join() 함수로 기다립니다. async() 함수로 시작된 블록은 await() 함수로 기다립니다. 하지만 runBlocking() 함수로 시작된 블록은 아무런 추가 함수 호출 없이 해당 블록이 완료될때까지 기다릴수 있습니다.
2. 주의해야 할것은 runBlocking 코루틴 블록이 사용하는 스레드는 현재 runBlocking() 함수가 호출된 스레드가 된다는 것입니다.
3. 안드로이드 의 경우 runBlocking() 함수를 메인 스레드 (UI 스레드) 에서 호출하여 시간이 오래 걸리는 작업을 수행하는 경우 ANR 이 발생할 위험이 있으므로 주의해야합니다.
코루틴 에서의 작업 취소
코루틴의 완벽한 제어를 위해서는 작업을 기다리고, 완료된 작업의 결과를 반환 받아서 처리하는것 뿐만아니라 작업의 취소 까지도 처리할수 있어야 합니다.
제어에 사용 되는 Job 클래스 와 Deferred 클래스 에는 코루틴 블록의 작업을 취소하기 위한 cancel() 함수가 존재합니다.
다음은 500 밀리초 간격으로 특정 문자열을 1000회 출력하는 코루틴 블록입니다. 이 블록을 시작한 이후 1300 밀리초가 지나면 코루틴을 취소합니다.
fun cancellingCoroutineExecution() = runBlocking {
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancel()
job.join()
println("main: Now I can quit.")
}
결과:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
ㄴ이해안가는 거 : (job.join()은 꼭 넣어야하는 거...?)
3번의 코루틴 블록의 수행 이후 취소 됐고, 원하는 대로 종료되었습니다.
다음은 위의 예시와 동일한 일을 수행하는 코루틴 블록 입니다.
단, 코틀린 에서 제공하는 repeat() 함수 와 delay() 함수를 사용하지 않고
while 문과 시스템 시간을 이용해 직접 500 밀리초 간격으로 작업을 수행하도록
작성해 보았습니다.
fun cancellationIsCooperative() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 10) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")
}
결과:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
job: I'm sleeping 5 ...
job: I'm sleeping 6 ...
job: I'm sleeping 7 ...
job: I'm sleeping 8 ...
job: I'm sleeping 9 ...
main: Now I can quit.
1300 밀리초 후 취소를 시도했지만, 취소되지 않고 10회를 모두 수행한 후 종료 되었습니다.
코루틴의 코드는 취소가 가능하도록 협력해야 합니다.
즉, 우리가 작성하는 코루틴 코드도 취소가 올바로 동작할수 있도록 노력해야 합니다.
이는 강제가 아니기때문에 신경쓰지 않는다면
두번째 예제와 같이 취소 할수 없는 코드가 작성될수도 있습니다.
취소가 가능한 코루틴 블록 만들기
코루틴 블록이 취소 가능하게 하기 위한 방법은 다음과 같습니다.
1. kotlinx.coroutines 패키지 함수 사용
kotlinx.coroutines 패키지의 모든 기능은 취소 가능 하도록 작성되어 있습니다.
delay() 함수도 kotlinx.coroutines 패키지 에 속해 있기때문에 이를 사용한 첫번째 예제는 delay() 함수 에서 취소가 동작하고 코루틴 블록을 취소 할수가 있습니다.
또는 kotlinx.coroutines 패키지에 포함되어 있는 yield() 함수를 사용할수도 있습니다.
yield() 함수는 해당 위치에서 코루틴을 일시중단합니다. 이때 코루틴의 취소 여부를 확인하게 되므로 yield() 함수를 호출한 해당 위치에서 코루틴 취소가 가능해 집니다.
fun makingComputationCodeCancellableUsingYield() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 20) {
yield()
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")
}
kotlinx.coroutines 패키지의 함수들을 코루틴 블록안에 적극 활용하면,
해당 블록은 취소 가능해 집니다.
CoroutineScope 의 확장 프로퍼티 isActive 로 확인
CoroutineScope 에는 Boolean 값의 확장 프로퍼티 isActive 를 가지고 있습니다. 이 값을 통해 이 코루틴 블록이 아직 취소 되지 않았는지 상태를 확인할수 있습니다.
isActive 프로퍼티 는 다음과 같이 사용할수 있습니다.
fun makingComputationCodeCancellable() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")
}
이와 같이 명시적으로 코루틴 의 취소 여부를 확인하여 코루틴을 설계할수도 있습니다.
코루틴 작업 취소 시 마무리 작업 하기
네트워크 나 파일을 다루는 경우에 우리는 모든 작업이 정상 종료되거나, 혹은 비정상 종료될때 사용한 리소스를 정리할수 있도록 프로그래밍 해야 합니다.
코루틴 역시 취소 시에 마무리 작업을 할수 있습니다.
try-finally 문으로 마무리 작업 하기
try-finally 문으로 코루틴 블록을 감싸면, 작업을 종료할때 finally 의 블록 코드가 실행 됩니다.
fun closingResourcesWithFinally() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
println("job: I'm running finally")
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")
}
결과는 다음과 같습니다.
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.
finally 문 에서 기다리기
만약 finally 에서 어떤 리소스의 사용이 마무리 되기를 대기해야 한다고 가정해봅시다.
그렇다면 finally 안에서 delay() 함수를 이용해서 일정 시간 대기 후에 작업을 이어가면 된다고 생각할수 있습니다.
하지만 delay() 함수 역시 코루틴 블록의 취소에 영향을 받기 때문에 finally 안에서 사용할수 없습니다.
같은 이유로 kotlinx.coroutines 패키지의 함수들은 finally 문 에서 사용하면 CancellationException이 발생합니다.
이러한 동작이 반드시 필요한 경우 withContext() 함수를 사용할수 있습니다.
fun runNonCancellableBlock() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
withContext(NonCancellable) {
println("job: I'm running finally")
delay(1000L)
println("job: And I've just delayed for 1 sec because I'm non-cancellable")
}
}
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")
}
withContext() 함수와 NonCancellable 를 사용하면,
withContext() 함수 내의 코루틴 블록 은 취소되지 않습니다.
다음은 해당 코드의 수행 결과 입니다.
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
job: And I've just delayed for 1 sec because I'm non-cancellable
main: Now I can quit.
일정시간 이후 자동 취소 되는 코루틴 블록 만들기
간단히 말해서 Timeout 으로 동작하는 코루틴 블록을 작성하는 방법입니다.
다양한 이유로 코루틴 의 작업을 취소 할 필요가 있습니다. 그중 가장 빈번히 발생하는 이유는 유효한 처리 시간을 초과 하여 더이상의 작업이 무의미 하기 때문입니다.
withTimeout() 함수 사용
코루틴에는 Timeout 동작을 간단히 처리할수 있는 withTimeout() 함수를 제공하여 kotlinx.coroutines 패키지에 포함되어 있습니다.
withTimeout() 함수는 첫번째 인자로 작업을 수행할 시간, 두번째 인자로 수행할 블록 함수를 받습니다.
다음은 1300 밀리초 이후 작업을 취소 합니다.
withTimeout(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
작업 취소 시 TimeoutCancellationException 이 발생하기 때문에 작업 취소 보다는 애플리케이션이 강제 중지 된다고 표현하는게 맞습니다.
try-catch 를 이용해 TimeoutCancellationException 을 처리할수도 있지만, 코틀린에서 제공하는 withTimeoutOrNull() 함수를 이용할수도 있습니다.
withTimeoutOrNull() 함수 사용
withTimeoutOrNull() 함수도 kotlinx.coroutines 패키지에 포함되어 제공됩니다.
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
"Done"
}
println("Result is $result")
시간내 정상 종료 시 값을 반환할수도 있고, 만약 시간내 처리되지 못한다면 withTimeout() 함수 와는 다르게 TimeoutCancellationException 이 발생하지 않고, 값으로 null 이 반환 됩니다.