지나공 : 지식을 나누는 공간

코루틴(coroutine)에 대하여. 2편. 코루틴을 실행하자 본문

Tech/Kotlin & Coroutine

코루틴(coroutine)에 대하여. 2편. 코루틴을 실행하자

해리리_ 2021. 7. 23. 02:08

코루틴을 정리하면 비동기 처리를 순차적으로 간단하게 하고, 콜백을 대체할 수 있는 도구다.

뭐가 편해지는 건지 한번 코드로 확인해보자.

 

코루틴, 뭐가 편해지는걸까?

val user = fetchUserData()
textView text = user.name

위처럼 코드를 짜면 참 편하다. 바로 서버를 호출하고, 그 결과를 바로 텍스트뷰에 삽입할 수 있으니까.

하지만 위처럼 할 수는 없다. 서버를 호출하는 부분은 네트워크 부분이기 때문에 NetworkOnMainThreadException이 발생하기 때문이다. 지난 포스팅에서 말했듯, 네트워크 작업은 main thread가 아니라 IO thread에서 작업하는 것이다. 근데 지금은 메인스레드에서 네트워크 콜을 하고 있네? 문제다.

 

fetchUserData { user -> //callback
	textView.text = user.name
}

이는 고쳐보려고 한 코드이다. 새로 fetchUserData라는 서버를 call 하는 함수 안에서 스레드를 이용해서 서버를 콜하고, 다시 메인 스레드로 switch해서 콜백을 준 다음에 ui를 처리하도록!. 이러면 이게 콜백 지옥이 되고 나중에 메모리도 지옥 같아 진다.

 

* 콜백이란?

더보기

나중에 실행할 argument에 다른 함수를 전달한 함수를 말한다. -> 나중에(back) 호출할(call) 함수.

var func = function(callback){
	console.log('hello');
    callback();
}

val callback = function(){
	console.log('hello2');
}
func(callback);
//hello1
//hello2

위 코드처럼 func 함수가 먼저 호출되고, 나중에 새롭게 정의한 callback 이라는 함수가 호출된다. callback 이라는 함수는 이름도 그렇고, 여기서 콜백함수다. func가 실행되고 인자로 받아져서 실행되니까.

예제를 하나 더 보자면,

// button 태그요소들을 Select한다
const button = document.querySelector('button')

// clicked 클래스를 요소에 추가하는 함수이다.
function clicked (e) {
  this.classList.add('clicked')
}

// click 함수를 콜백 함수로써 event listener에 등록한다.
button.addEventListener('click', clicked)

button.addEventListener가 실행되고, 그 안에서 인자로 받았던 clicked가 실행되니까 clicked가 콜백함수다.

또다른 예제로,

//일반 방식
fun loadUser() {
	val user = api.fetchUser()
    show(user)
}

위처럼 되면 앱은 죽는다. api.fetchUser()가 시작되면 얘는 네트워크이다 보니 메인 스레드는 blocking되기 때문에, 메인 스레드에서만 가능한 화면 단의 작업을 하지 못한 채로 그냥 show(user)가 되어버린다. 이것도 뭐 콜백으로 다시 구현되게 할 수는 있으나 콜백 지옥이 되기 때문에 별로다.

 

이런 것들을 해결하는 방식 중 가장 강력히 구글에서 추천하는 게 코틀린의 코루틴이다.

//코루틴
suspend fun loadUser() {
	val user = api.fetchUser()
    show(user)
}

이렇게 하면 콜백도 없고, 별도의 스레드에 다녀오는 처리도 없었지만 실제로는 새로운 스레드가 생성되고 네트워크가 완료된 다음 다시 ui 업데이트를 한다. 콜백을 한 것처럼 똑같이 동작한다 콜백을 안했는데도.

 

한번 코루틴을 써보자!

import kotlinx.coroutines.*

fun main(){

	GlobalScope.launch{ //새로운 코루틴을 백그라운드에서 실행시킴
    	delay(1000L)
        println("World")
    }
    println("Hello ") //메인스레드는 코루틴이 지연되는 동안 계속 실행됨.
    Thread.sleep(2000L)
}

코드 순차적으로만 보면 world hello가 찍혀야 할 것 같지만, 실제로는 Hello가 먼저 찍히고 world가 찍힌다.

 

만약 동기적으로 실행된다면 1초를 기다린 뒤 world가 출력되고 hello가 출력되고 2초를 기다린 뒤 프로그램이 종료된다.

 

하지만 비동기처리이기 때문에 다른 것에 영향을 받지 않고 즉시 자기 코드를 실행한다. GlobalScope.launch를 통해 시작된 코루틴이 백그라운드에서 돌기 시작하고, 메인 스레드의 코드들은 그냥 코루틴이 지연되는 동안 계속 실행된다.

 

그래서 두 블럭이 동시에 실행되면서 0초에 헬로출력 / 1초째에 월드출력 / 2초째에 프로그램종료 와 같이 실행된다.

 

launch 는 코루틴 빌더로, 코루틴이 만들어지는 부분이다. 그리고 만들어진 코루틴 블록에서 world 가 프린트된다.

launch는 내부적으로 어떤 코루틴을 만들어서 반환한다. 근데 이 launch를 시작하려면 GlobalScope가 필요하다. GlobalScope는 어플리케이션 전체의 생명주기를 따른다.

 

suspend 함수 사용

suspend함수는 코루틴 블록에서 사용할 수 있다. suspend 함수는 launch, async에서 사용할 수 있지만 메인 스레드에서는 사용하지 못한다. 그리고 또 다른 suspend 함수 내에서 사용하거나 코루틴 블록에서만 사용해야 한다. 나중에 더 자세히 보자.

launch와 async에 대해

launch는 현재 자기가 속해 있는 스레드를 차단하지 않은 채로 새로운 코루틴을 실행할 수 있게 하고, 특정 결과 값 없이 job 객체를 반환한다. 아래 코드를 보면 일단 job을 반환해버리는 걸 알 수 있다.

import kotlinx.coroutines.*

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.isComplted}")
}
//Hello
//job.isActive:true, completed:false
//World
//job.isActive: true, completed:true

async도 새로운 코루틴을 실행할 수 있는데 launch와 다른 점은 Deffered<T>를 통해 결과값을 반환한다는 점이다. 이때 지연된 결과값을 받기 위해 await()을 사용할 수 있다. 이 부분은 나중에 이어서 더 이야기해보자.

 

코루틴이 경량 스레드라며?

코루틴이 경량 스레드라고 한다. 그러니까 이번에는 코루틴을 만드는 launch 를 대체해서 쓰레드를 만드는 것으로 바꿔보자. 

import kotlinx.coroutines.*

fun main() {
	thread{
    	Thread.sleep(1000L)
        println("World")
    }
    println("Hello ")
    Thread.sleep(2000L)
}

위 결과는 코루틴으로 했을 때랑 완전히 똑같은 결과가 나온다.

정리하자면,

  • 본질적으로 코루틴은 경량스레드다. 
  • launch 는 코루틴빌더다.
  • CoroutineScope : luanch를 하려면 스코프가 필요하다.
  • 그 중에서도 애플리케이션 전반의 생명주기를 따르는 GlobalScope가 있다.

 

RunBlocking에 대해

thread.sleep은 메인스레드를 블로킹한다. 이걸 runBlocking으로 만들어보자.

*논블로킹, 블로킹, 동기, 비동기

더보기

- 동기와 비동기 :

작업을 수행하는 주체가 두 개 이상일 때 나오는 개념으로, 동기는 남의 작업에서 응답이 나와야 내 작업을 실행한다. 비동기는 두 주체가 서로의 시작과 종료를 신경쓰지 말고 각자의 시간대로 수행한다.

 

- 블로킹과 논블로킹 :

작업의 대상이 두 개 이상일 때 나오는 개념으로, 블로킹이라면 자기 작업을 하다가 다른 누군가의 작업이 시작될 때 멈춰서 끝날 때까지 기다려준다. 논블로킹은 다른 주체의 작업과 상관 없이 그냥 자기 작업을 계속한다. 

 

이걸 구현하려면 코드에서 sleep 대신 delay를 사용하면 된다. 하지만 delay는 suspend 함수에서 사용되는 거라서 suspend가 아닌 여기서는 쓸 수 없다.

 

어쨌든 흐름을 blocking 한 뒤 작업하게 해야 하는데 그게 바로 runBlocking 이다. runBlocking은 새로운 코루틴을 실행하고 완료되기 전까지 현재의 스레드를 블로킹한다. 

 

import kotlinx.coroutines.*

fun main() {
	GlobalScope.launch{ //새로운 코루틴을 백그라운드에 실행 
    	delay(1000L)//논블로킹
        println("World")
    }
   
    println("Hello ") 
    runBlocking{
    	delay(2000L)
    }
}

runBlocking은 아까 launch처럼 코루틴 빌더인데, launch는 자기를 호출한 스레드를 블로킹하지 않고, runBlocking은 자기를 호출한 스레드를 블로킹한다는 차이가 있다. 블로킹 코루틴을 만들어서 반환한다.

 

이번에는 메인스레드를 블로킹하는 코루틴을 만들어서 지금까지의 출력코드를 모두 그 안에 넣어보자.

import kotlinx.coroutines.*

fun main() {
	runBlocking{
		GlobalScope.launch{
    		delay(1000L)
        	println("World")
    	}
   
    	println("Hello ")
    	delay(2000L)
    }
}

이러면 runBlocking 내부에 있는 애들이 실행완료되기 전까지는 메인함수가 리턴되지 않는다.

코드를 아래와 같이 바꿀 수도 있다.

import kotlinx.coroutines.*

fun main() = runBlocking{
		GlobalScope.launch{
    		delay(1000L)
        	println("World")
    	}
   
    	println("Hello ")
    	delay(2000L)
    }

 

Waiting for a job

이번 예제에서는 delay를 쓰지 않고 job을 만들어서 기다릴 것이다. 사실 delay를 쓰는 게 별로 좋은 코드가 아니거든.

import kotlinx.coroutines.*

fun main() {
	runBlocking{
		GlobalScope.launch{
    		delay(3000L)
        	println("World")
    	}
   
    	println("Hello ")
    	delay(2000L)
    }
}

만약 위의 코드와 같다면, 헬로만 출력될 것이다.

일단 runBlocking 안에 코루틴과 delay가 있다. 그런데 코루틴은 3초에 world를 출력하고, Hello는 백그라운드 코루틴과 동시에 실해오디어 바로 hello 출력 -> 2초에 프로그램을 종료해버린다. 그래서 world가 출력되지 못한채로 종료된다.

 

이걸 해결해보자.

import kotlinx.coroutines.*

fun main() = runBlocking{
		val job = GlobalScope.launch{
    		delay(3000L)
        	println("World")
    	}
   
    	println("Hello ")
        job.join()
    }

launch를 하면 job이 반환되는데, 이 job 객체에 join을 하면 job이라는 코루틴이 종료될 때까지 기다렸다가 메인 함수가 종료된다.

위의 코드를 실행하면 0초에 hello가 찍힌 뒤에 바로 프로그램이 종료되지 않고 join에 의해 코루틴을 기다려준다. 그래서 3초 뒤에 world가 찍힌다.

 

Structured Concurrency

runBlocking과 launch 둘 사이에 현재는 구조적으로 관계가 없다. 그래서 runBlocking 내부가 실행되는 동안 얘를 건드릴 수는 없지만, 이 안에 있는 launch를 다 실행하지 못한 채로 함수가 종료될 수도 있다.

 

이걸 해결하기 위해서 우리는 이전 예제에서 코루틴의 완료를 기다리도록 sleep을 주기도 하고 job 객체를 만들어서 join을 통해 기다리기도 했었다.

만약 job.join이 없으면 코루틴의 모든 코드가 다 완료되기 전에 프로그램이 종료되어버릴 수 있기 때문에, 코루틴의 완료를 기다리고자 job.join을 모든 job 에 대해 쓰게 된다. 그러면서 코드도 더러워 질 것이다.

 

코틀린에서는 이런 걸 방지하기 위해서 Structured Concurrency를 얘기하고 있다. runBlocking과 그 안에 있는 launch들을 어떻게 구조적으로 연결할 수는 없을까?!

 

그래서. runBlocking안에서 새로 Global.scope을 통해 코루틴을 열지말고, 아래처럼 하자는 결론이 나왔다.

this.launch를 통해 runBlocking 에서 launch하기!!

this.launch 사용(좌) / this 안써도 됨.

그러면 이제 join을 하지 않았는데도 world가 출력되도록 다 기다려준다.

 

즉, top level coroutine을 만들지 말고 runBlocking의 코루틴의 child로 만들면, 부모 코루틴이 자식 코루틴의 완료까지 기다려주기 때문에 의도하는 것들을 구현할 수 있다.

 

Extract function refactoring

import kotlinx.coroutines.*

fun main() = runBlocking {
	launch {
    	myWorld()
    }
    println("Hello ")
}

fun myWorld() {
	delay(1000L) // 여기서 에러 발생
    println("world")
}

실행하면 에러가 난다. 코루틴이나 suspend function이 아닌데 delay를 호출했다고 에러가 난다.

그래서 우리는 suspend라는 키워드를 붙여줄 것이다. 그러면 이제 myWorld라는 함수도 일시중단할 수 있는 함수가 된다.

 

일시중단이라는 게 뭐냐..!

코루틴이 실행 중인 스레드를 블로킹하지 않으면서 실행 중인 코루틴을 잠시 중단시킬 수 있는 중단지점 함수.

  • suspend는 서스펜딩 함수를 호출하는 시점에 현재 실행 중인 코루틴은 잠시 중단되고, 그 때 남게 되는 스레드는 다른 코루틴에 할당될 수 있음을 의미한다.
  • 서스펜딩 함수의 로직이 끝났을 때에 중단되었던 코루틴은 다시 실행 준비가 된다.

 

 

이렇게 일시중단 마크가 생긴다.

suspend function은 suspend function이나 코루틴 안에서만 호출될 수 있다. delay가 suspend function이라서 suspend나 코루틴 안에서 호출될 수 있다!

 

가벼운 코루틴

fun main() = runBlocking {
	repeat(100_000) {
    	launch {
        	delay(1000L)
            print(".")
    }
}

fun main() = runBlocking {
	repeat(100_000) {
    	thread {
        	Thread.sleep(1000L)
            print(".")
    }
}

레퍼런스는 코루틴이 스레드보다 가볍다는 말을 하고 싶어한다.  그리고 out of memory가 잘 나지 않는다고. 스레드는 너무 많으면 시스템 성능에 따라 메모리 오류가 날 수 있는데 코루틴은 많이 해도 괜찮단거지.

 

Global coroutines are like daemon thread.

데몬 스레드처럼, 코루틴이 동작할 때 프로세스가 유지되지는 않는다.

fun main() = runBlocking {
	GlobalScope.launch{
    	repeat(1000) { i ->
        	println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L)
}

코루틴이 시작되고 1000번 반복되면서 출력된다. 근데 매번 500L씩 기다리니까 500000L만큼의 시간이 걸리고, 프로그램은 오히려 1.3초 뒤에 종료된다. 그렇게 될 때 과연 코루틴은 살아남을까? 가 이 코드에서 질문이 된다.

 

메인함수가 끝나도 코루틴이 살아있다면 가능할텐데 살지 못한다. 즉, 프로세스가 끝나면 코루틴도 끝난다.

 

정리

  • Coroutine Builder - 코루틴을 만들 때 필요함.
    • launch 
    • runBlocking
    • job이라는 반환 객체가 있었음.
  • Scope - 빌더들은 스코프 안에서 사용됐다.
    • Global scope : 프로그램 전체를 생명주기로 갖는.
    • Coroutine scope :
  • Suspend function
    • suspend
    • delay()
    • join()
  • Structured Concurrency : Join 하지 않아도 여러 코루틴들을 기다려주는 방법.

일시중단(suspend)와 <-> resume(체험)

fun main() = runBlocking{
	launch {
    	repeat(5) { i ->
        	println("Coroutine A, &i")
        }
    }
    
    launch {
    	repeat(5) { i ->
        	println("Coroutine B, &i")
        }
    }
    println("Coroutine Outer")
}

여기서 아래 코드를 추가해서 스레드를 찍어보자.

fun <T> println(msg : T) {
	kotlin.io.println("$msg [$Thread.currentThread().name}}"}
}

다 메인이네.

VM option에 -Dkotlinx.coroutines.debug를 넣어서 edit configuration을 설정하면 어떤 코루틴에서 찍힌 건지도 확인할 수 있다.

첫번째 코루틴은 코루틴 빌더인 runBlocking에 의해 만들어졌다. 2번째는 launch, 3번째도 launch에 의해 만들어졌다.

 

여기서 두번째 코루틴에 delay()를 넣어보자. 그러면 일시중단 마크가 나타난다. 그 이유는, delay함수안에 들어가면 suspend이다. 즉, delay를 만났을 때 얘를 호출한 launch 코루틴이 일시중단 될 수 있기 때문이다.

fun main() = runBlocking{
	launch {
    	repeat(5) { i ->
        	println("Coroutine A, &i")
            delay(10L) //여기서 이 launch 코루틴이 잠시 일시중단될 수 있어요~
        }
    }
    
    launch {
    	repeat(5) { i ->
        	println("Coroutine B, &i")
        }
    }
    println("Coroutine Outer")
}

 

이번엔 두 코루틴에 delay를 넣어보자.

fun main() = runBlocking{
	launch {
    	repeat(5) { i ->
        	println("Coroutine A, &i")
            delay(10L) //여기서 이 launch 코루틴이 잠시 일시중단될 수 있어요~
        }
    }
    
    launch {
    	repeat(5) { i ->
        	println("Coroutine B, &i")
            delay(10L)
        }
    }
    println("Coroutine Outer")
}

 

그랬더니! 첫번째 코루틴 실행하다가 suspend func인 delay가 호출되면서 일시중단되고, 그 동안 b 가 실행되고, b가 다시 일시중단 함수로 인해 일시중단이 되면서 다시 a 가 일어난다.

 

출처

새차원의 코루틴

https://m.blog.naver.com/horajjan/221568780755

728x90
Comments