/* 본 게시물은 'Do it 코틀린 프로그래밍 | 황영덕 지음' 의 내용을 토대로 작성되었습니다. */
참고 자료
[쾌락코딩 - 코틀린 코루틴 개념익히기] :https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/
[Android Developers - 코루틴]
[Android] 코틀린 코루틴 한번에 끝내기
: https://whyprogrammer.tistory.com/596
#코루틴
1. 동시성 프로그래밍
프로그래밍에서 순서대로 작업을 수행하여 1개의 루틴을 완료한 후 다른 루틴을 실행하는 방식을 동기적으로 수행한다고 말한다. 현재 안드로이드 앱 개발에서는 화면에서 로딩되는 UI를 보여줄 때 네트워크에 접속하여 데이터를 받아오고, 혹은 데이터베이스에 접근해서 데이터를 가져오는 등 여러 기능들을 비동기 방식으로 처리한다. (동기 방식으로 처리하면 앱의 성능이 많이 다운/ 앱이 멈추는 현상 발생) 여러 개의 루틴이 선행 작업의 순서나 완료 여부와 상관없이 실행 되는 방식을 비동기적이라고 한다.
이러한 비동기 프로그래밍은 RxJava, Reactive와 같은 서드파티 라이브러리에서 제공하고 있다. 코틀린에서는 코루틴을 서드파티가 아닌 기본으로 제공하고 있다. 앱의 성능을 향상시키기 위해서는 블로킹된 코드를 넌블로킹 기법으로 코드를 바꾸는 방법이 있다. 보통 다중 작업을 하려면 스레드와 같은 비동기 코드를 작성해야 하는데 이때 코드의 복잡성 또한 증가한다. 하지만 코틀린의 코루틴을 사용하면 넌블로킹 또는 비동기 코드를 마치 동기 코드처럼 쉽게 작성하면서도 비동기 효과를 낼 수 있다. (비동기 프로그래밍의 입문이 쉽다 But 배울수록 RxJava 배우는 것이랑 비슷하다고 함)
동시성 프로그래밍의 예시로 양쪽에 놓여진 두 개의 도화지에 사람 그림을 각자 그린다고 가정해보자. 동시성 프로그래밍이란 오른쪽 손에만 펜을 쥐고서 왼쪽 도화지에 사람 일부를 조금 그리고, 오른쪽 도화지에 가서 잠시 또 사람을 그리고, 다시 왼쪽 도화지에 사람을 찔끔 그리고… 이 행위를 아주 빨리 반복하는 것이다. 사실 내가 쥔 펜은 한 순간에 하나의 도화지에만 닿는다. 그러나 이 행위를 멀리서 본다면 마치 동시에 그림이 그려지고 있는 것 처럼 보일 것이다. 이것이 동시성 프로그래밍이다. (병렬성이랑은 다른 개념이다)
cf. 프로세스와 스레드 + 문맥 교환 (알아두면 좋음)
비동기 프로그래밍을 하기 전에 앞서 프로세스와 스레드의 개념을 공부할 필요가 있다.
태스크는 큰 실행 단위인 프로세스나 좀더 작은 실행 단위인 스레드를 말한다. 하나의 프로그램이 실행되면 프로세스가 시작되는데 프로세스는 실행되는 메모리, 스택, 열린 파일 등을 모두 포함하기 때문에 프로세스 간 문맥 교환을 할 때 많은 비용이 든다. 반면 스레드는 자신의 스택만 독립적으로 가지고 나머지는 대부분 스레드끼리 공유하므로 문맥 교환 비용이 낮아 프로그래밍에서 사용된다. 다만 여러 개의 스레드를 구성하면 코드가 복잡해진다. 이러한 멀티 스레드를 구현하기 위해서는 저수준에서 운영체제의 개념과 스케줄링, 스레드와 프로세스에 대한 깊은 이해가 있어야 한다.
Point. 문맥 교환, 프로세스와 스레드
문맥 교환 혹은 컨텍스트 스위칭은 운영체제론에서 많이 사용되는 용어이다. 문맥 교환이란 하나의 프로세스나 스레드가 cpu를 사용하고 있는 상태에서 다른 프로세스나 스레드가 cpu를 사용하도록 하기 위해, 이전의 프로세스의 상태를 보관하고 새로운 프로세스의 상태를 적재하는 과정을 말한다. 그렇다면 프로세스나 스레드는 어떻게 구성될까?
위 사진을 보면 프로세스는 코드, 데이터, 열린 파일의 식별자, 동적 할당 영역, 스택 등을 가지고 있는데, 이러한 것들을 문맥이라고 한다. 프로세스와 프로세스는 서로 완전히 독립되어 있기 때문에 프로세스 간의 실행을 전환하려면 이러한 문맥을 저장해 두었다가 새로운 프로세스의 문맥을 불러들이는 과정을 거쳐야한다.
스레드는 프로세스의 코드, 데이터, 열린 파일 등을 공유하는 작은 독립된 실행 단위이다. 스레드는 레지스터와 스택만 독립적으로 가지고 있기 때문에 스레드 간 문맥 전환은 프로세스 간 전환보다 훨씬 빠르고 운영체제 입장에서 비용이 낮다.
보통 멀티코어 프로세서를 가진 시스템에서 성능을 위해서 스레드 풀을 사용해 미리 초기화해 둔 스레드를 사용하고 있다. JVM에서는 Runnable 태스크를 가진 Executor에 의해 사용된다. 코루틴은 이러한 자바의 메커니즘을 이용하고 있다.
2. 코루틴
응용 프로그래머 입장에서 운영체제를 깊게 이해하지 않더라도 동시성 프로그래밍을 이용할 수 있는 방법이 있을까? 코틀린에서는 새롭게 등장한 코루틴 개념을 사용하면 이러한 전통적인 스레드 개념을 만들지 않고 좀 더 쉽게 비동기 프로그래밍을 할 수 있다. 코루틴은 문맥 교환이 없고 최적화된 비동기 함수를 통해 비선점형으로 작동하는 특징이 있어 협력형 멀티태스킹을 구현할 수 있게 해준다. (코루틴은 개념자체로만 보면 병렬성이아니라 동시성을 지원하는 개념이다.)
Point. 협력형 멀티태스킹이란?
프로그램에서 태스크를 수행할 때 운영체제를 사용할 수 있게 하고 특정한 작업에 작업 시간을 할당하는 것을 '선점한다'라고 한다. 선점형 멀티태스킹은 운영체제가 강제로 태스크의 실행을 바꾸는 개념이고 협력형 멀티태스킹은 태스크들이 자발적으로 양보하며 실행을 바꿀 수 있는 개념이다.
코루틴도 routine이기 때문에 하나의 함수로 생각해보자. 이 함수는 함수에 진입할 수 있는 진입점도 여러개고, 함수를 빠져나갈 수 있는 탈출점도 여러개다. 즉, 코루틴 함수는 꼭 return 문이나 마지막 닫는 괄호를 만나지 안더라도 언제든지 중간에 나갈 수 있고, 언제 든지 다시 나갔던 그 지점으로 들어올 수 있다. 아래 링크로 들어가서 예시를 보면 이해가 잘 될 것이다.
관련글
[쾌락코딩 - 코틀린 코루틴 개념익히기] : https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/
코루틴은 동시성 프로그래밍이다.
코틀린의 특징으로는 코루틴은 스레드 위에서 실행되는데 여러가지 코루틴이 존재한다고 할 때 1,2,3이 있다고 가정할 때 코루틴 1을 실행하던 중 2가 실행되도 실행중인 스레드를 정지하면서 기존 스레드에서 코루틴2를 실행하게 된다.
코루틴의 장점으로는 문맥 교환 없이 해당 루틴을 일시 중단해서 기존 스레드 기법보다 비용이 적게 든다.
위 그림처럼 코루틴은 함수에 suspend 키워드만 붙여주면 함수 실행 중간에 나갈 수도 있고, 다시 들어올 수 도 있는 자격이 부여되는 것이다.
3. 코루틴 사용해보기
1. 의존성 추가
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
GlobalScope.launch { // 새로운 코루틴을 백그라운에 실행
delay(1000L) // 1초 넌블로킹 지연(시간의 기본 다위는 ms)
println("World!")
}
println("Hello,") // 메인 스레드의 코루틴이 지연되는 동안 계속 실행
Thread.sleep(2000L) // 메인 스레드가 JVM에서 바로 종료되지 않게 2초 기다림
실행 결과
위에 예시처럼 launch를 통해 코루틴 블록을 만들어 내는 것을 코루틴 빌더의 생성이라고 한다.
launch는 현재 스레드를 차단하지 않고 새로운 코루틴을 실행할 수 있게 하며 특정 결괏 값 없이 Job 객체를 반환한다.
GlobalScope은 코루틴의 실행 범위를 결정한다. 이것은 코루틴의 생명 주기가 프로그램의 생명주기에 의존하므로 main()이 종료되면 같이 종료된다. 코루틴을 실행하기 위해서는 내부적으로 스레드를 통해서 실행될 수 있다. 단 실행 루틴이 많지 않은 경우에는 내부적으로 하나의 스레드에서 여러 개의 코루틴을 실행할 수 있기 때문에 1개의 스레드면 충분하다.
fun main() {
val job = GlobalScope.launch {
delay(1000L)
println("World!")
}
println("Hello,")
println("job.isActive : ${job.isActive}, completed: ${job.isCompleted}")
Thread.sleep(2000L)
println("job.isActive : ${job.isActive}, completed: ${job.isCompleted}")
}
실행 결과
이제 suspend(지연) 함수에 대해 알아보자.
suspend
코루틴에서 사용되는 함수는 suspend()로 선언된 지연 함수여야 코루틴 기능을 사용할 수 있다. suspend로 표기함으로서 이 함수는 실행이 일시 중단될 수 있으며 필요한 경우에 다시 재개할 수 있게 된다.
컴파일러는 suspend가 붙은 함수를 자동적으로 추출해 Continuation 클래스로부터 분리된 루틴을 만든다. 이러한 지연 함수는 코루틴 빌더인 launch와 async에서 사용할 수 있지만 메인 스레드에서는 사용할 수 없다. 지연 함수는 또 다른 지연 함수 내에서 사용하거나 코루틴 블록에서만 사용해야 한다.
fun main() {
//순차적 실행
GlobalScope.launch {
val school = goSchool()
val home = goHome()
println(school)
println(home)
}
readLine() // main()이 먼저 종료되는 것을 방지
}
suspend fun goSchool(): String{
delay(1000)
return "arrive school"
}
suspend fun goHome(): String{
delay(2000)
return "arrive Home"
}
실행 결과
goSchool()과 goHome() 함수는 순차적으로 표현할 수 있다. 2개의 함수는 내부적으로 비동기 코드로서 동시에 작동할 수 있지만 코드만 봤을 때는 순차적으로 실행되는 것처럼 표현함으로서 프로그래밍의 복잡도를 낮춘다.
이번에는 async을 사용해보자.
async도 새로운 코루틴을 실행할 수 있는데 launch와 다른 점은 Deffered<T>를 통해 결괏값을 반환한다는 점이다. 이때 지연된 결과값을 받기 위해 await()을 사용할 수 있다.
fun main() {
works()
readLine()
}
private fun works(){
val one = GlobalScope.async {
goSchool()
}
val two = GlobalScope.async {
goHome()
}
GlobalScope.launch{
val combined = one.await() + " " + two.await()
println("Combined : $combined")
}
}
goSchool()과 goHome는 async에 의해 감싸져 있으므로 완전히 병행 수행할 수 있다. 여기서는 delay()로 1초만 지연시킨 goSchool()이 먼저 종료되는 것을 예측할 수 있다. 하지만 좀 더 복잡한 루틴을 작성하는 경우에는 많은 태스크들과 같이 병행 수행되므로 어떤 루틴이 먼저 종료될지 알기 어렵다. 따라서 태스크가 종료되는 시점을 기다렸다가 결과를 받을 수 있도록 await()을 사용해 결과를 가져올 수 있다.
3. runBlocking 사용
runBlocking은 새로운 코루틴을 실행하고 완료되기 전까지 현재 스레드를 블로킹한다.
앞선 예제에서 메인 스레드 자체의 종료를 막기 위해 readLine을 사용했는데 메인 스레드 자체를 잡아두기 위해 runBlocking을 사용할 수 있다.
fun main() {
runBlocking {
val job = launch {
val home = goHome()
val school = goSchool()
println(home + " " + school)
}
}
println("Hello")
//delay(2000L) or readLine() 생략 가능
job.join()
}
명시적으로 코루틴의 작업이 완료되는 것을 기다리게 하려면 Job 객체의 join() 함수를 사용하면 된다.
4.async() 함수의 시작 시점 조절하기
async에서 start 매개변수를 사용하면 async() 함수의 시작 시점을 조절할수 있다. 사용할 수 있는 시작 시점은 다음과 같다.
- DEFAULT: 즉시 시작
- LAZY : 코루틴을 느리게 시작(처음에는 중단된 상태이며 start()나 await() 등으로 시작됨
- ATOMIC : 최적화된 방법으로 시작
- UNDISPATCHED: 분산 처리 방법으로 시작
fun main() { runBlocking {
val time = measureTimeMillis {
val one = async(start = CoroutineStart.LAZY){ goSchool()}
val two = async(start = CoroutineStart.LAZY){ goHome()}
println("Await: ${one.await()} ${two.await()}") // 이 줄을 실행할 때 코루틴 시작.
}
println("Completed: $time ms")
}
}
이후 추가적인 내용은 다음편에서..