[kotlin] 프로세스, 스레드, 코루틴

코루틴을 이해하기 위한 선행 조건

코루틴을 이해하기 위해서는 프로세스와 스레드 그리고 비동기를 이해해야만 한다.

 

애플리케이션을 시작할 때 운영체제는 프로세스를 생성하고

여기에 스레드를 연결한 다음,

메인 스레드(main thread)를 시작한다.

 

프로세스?

프로세스는 실행 중인 애플리케이션의 인스턴스다. 애플리케이션은 여러 프로세스로 구성될 수 있다.

스레드?

실행 스레드는 프로세스가 실행할 일련의 명령을 포함한다.

스레드가 끝나면 프로세스의 다른 스레드와 상관없이 프로세스가 종료된다.

fun main(args: Array<String>) {
		doWork()
}

 

애플리케이션이 실행되면 main() 함수의 명령 집합이 포함된 메인 스레드가 생성된다.

doWork()은 메인 스레드에서 실행되므로

doWork()이 종료되면 애플리케이션의 실행이 종료된다.

 

스레드 안에서 명령은 한 번에 하나씩 실행돼 스레드가 블록(block)되면 블록이 끝날 때까지 같은 스레드에서 다른 명령을 실행할 수 없다.

그러나 많은 스레드가 같은 프로세스에서 생성될 수 있으며 서로 통신할 수 있다.

 

코틀린이 동시성을 구현한 방식을 보면 여러분이 직접 스레드를 시작하거나 중지할 필요가 없다는 것을 알게 된다.

한두 줄의 코드로 코틀린이 특정 스레드나 스레드 풀을 생성해서 코루틴을 실행하도록 지시하기만 하면 된다.

스레드와 관련된 나머지 처리는 프레임워크에 의해 수행된다

 

코루틴

코틀린 문서에서는 코루틴을 경량 스레드라고도 한다.

대부분의 스레드와 마찬가지로 코루틴이 프로세서가 실행할 명령어 집합의 실행을 정의하기 때문이다. 또한 코루틴은 스레드와 비슷한 라이프 사이클을 갖고 있다.

코루틴은 스레드 안에서 실행된다. 스레드 하나에 많은 코루틴이 있을 수 있지만 주어진 시간에 하나의 스레드에서 하나의 명령만이 실행될 수 있다. 즉 같은 스레드에 10개의 코루틴이 있다면 해당 시점에는 하나의 코루틴만 실행된다.

스레드와 코루틴의 가장 큰 차이점은 코루틴이 빠르고 적은 비용으로 생성할 수 있다는 것이다. 수천 개의 코루틴도 쉽게 생성할 수 있으며, 수천 개의 스레드를 생성하는 것보다 빠르고 자원도 훨씬 적게 사용한다.

다음 함수는 파라미터 amount에 지정된 수만큼 코루틴을 생성해 각 코루틴을 1초 간 지연시킨 후 모든 코루틴이 종료될 때까지 기다렸다가 반환한다.


출처: https://kotlinworld.com/139 [조세영의 Kotlin World:티스토리]

그림1의 메인 스레드(Main Thread)를 보자.

JVM프로세스는 메인 스레드가 main 함수를 실행하며 시작되며, 만약 프로세스 내에 사용자 스레드가 메인 스레드 밖에 없는 상황에서는 메인 스레드가 종료되면 프로세스 또한 강제로 종료되는 특성을 가진다. 이때 메인 스레드는 한 번에 하나의 작업을 실행 가능하다.

그런데 그림에서는 메인 스레드말고 다른 2개의 스레드(Thread)가 보인다. 이 스레드(Thread)들은 사용자에 의해 생성되는 스레드로,  메인 스레드와 마찬가지로 작업을 수행할 수 있다.

 

안드로이드 앱을 예제로 들면 메인 스레드는 가장 중요한 스레드로, UI를 그려주고 사용자가 화면을 눌렀을 때 이벤트를 전달받는 스레드이다. 즉, 사용자와의 인터렉션을 담당하는 스레드이다. 만약 이 Thread가 높은 부하를 받는 작업에 의해 블로킹된다면 안드로이드 앱은 멈춤 현상이 생기고, 일정 시간 이상 블로킹될 때 앱은 강제로 종료된다. 따라서 메인 스레드에서 많은 부하를 받는 작업은 지양해야 하며, 다른 스레드를 생성해 해당 스레드에 높은 부하를 주는 작업을 수행하도록 만들어야 한다.

기존의 접근 방식과 한계점

그 동안 코틀린 애플리케이션에서는 메인 스레드가 블로킹되는 문제를 해결하기 위해 많은 방법이 시도되었다. 가장 대표적인 방법으로는 다음의 방식들이 있다.
 

Thread 클래스를 상속하는 방법

Thread 클래스를 상속하는 새로운 클래스(아래에서는 ExampleThread)를 만들고, run 메서드를 override 하면, 새로운 스레드에서 실행될 작업을 정의할 수 있다. 새로운 클래스의 인스턴스를 만들어 start 함수를 호출하면, 새로운 스레드에서 작업이 실행된다. 

fun main() {
    val exampleThread = ExampleThread()

    exampleThread.start()
}

class ExampleThread : Thread() {
  override fun run() {
    println("[${Thread.currentThread().name}] New Thread Running")
  }
}

/*
출력
[Thread-0] New Thread Running
*/


하지만 이 방법으로 생성한 스레드의 인스턴스는 많은 메모리를 차지하면서, 재사용이 어려운 단점이 있었다. 또한 스레드를 개발자가 직접 생성하고 관리해야 하기 때문에 메모리 누수의 가능성이 올라간다.
 
이런 문제를 해결하기 위해서는 한 번 생성한 스레드의 재사용이 용이해야 하며 생성된 스레드의 관리가 개발자가 아닌 미리 구축한 시스템이 할 수 있어야 한다. 이러한 역할을 하기 위해 Executor 프레임웍이 등장한다.

 

Executor 프레임웍을 사용하는 방법

Executor 프레임웍은 개발자의 스레드 관리에 대한 책임을 낮추고 생성된 스레드 인스턴스의 재사용을 높였다. Executor 프레임웍은 사용자의 요청에 따라 스레드의 집합인 '스레드 풀'을 생성하고, 사용자가 작업을 제출하면 이 스레드 풀의 스레드 중 하나에 작업을 할당한다. 다음은 그 예시이다.

fun main() {
  // ExecutorService 생성
  val executorService: ExecutorService = Executors.newFixedThreadPool(4)

  // 작업 제출
  executorService.submit {
    println("[${Thread.currentThread().name}] 새로운 작업1 시작")
  }

  // 작업 제출
  executorService.submit {
    println("[${Thread.currentThread().name}] 새로운 작업2 시작")
  }

  // ExecutorService 종료
  executorService.shutdown()
}

/*
출력
[pool-1-thread-1] 새로운 작업1 시작
[pool-1-thread-2] 새로운 작업2 시작
*/

 


Rx 라이브러리를 사용하는 방식

Rx 라이브러리는 엄밀히 Reactive Programming을 돕기 위한 라이브러리이며, 데이터 스트림을 정의하고 데이터 스트림을 구독해 처리할 수 있게 하는 라이브러리이다. 라이브러리 내부에서는 subscribeOn, observeOn 메서드를 통해을 통해 데이터를 발행하는 스레드를 데이터를 구독하는 스레드를 손쉽게 분리할 수 있게 하지만, 간단한 작업들도 모두 데이터 스트림으로 만들어야 했기에 불편함이 존재했다.
publisher.subscribeOn(Schedulers.io())
         .observeOn(AndroidSchedulers.mainThread())​

 

기존 접근 방식들의 한계

위와 같은 기존 접근 방식들의 한계점은 작업의 단위가 스레드라는 점이다. 메인 스레드의 블로킹을 방지하기 위해 다른 스레드로 작업을 넘기면 된다고 했는데 작업의 단위가 스레드인 것이 문제라니 도대체 무슨 말인가 싶을 것이다.
 
 스레드는 생성 비용이 비싸고 작업을 전환하는 비용이 비싸다. 또한 한 스레드가 다른 스레드부터의 작업을 기다려야 하면, 기본적으로 다른 스레드의 작업을 기다리는 스레드는 다른 작업이 스레드를 사용할 수 없도록 Blocking 된다. 이렇게 되면 해당 스레드는 하는 작업 없이 다른 작업이 끝마쳐질 때까지 기다려야 하기 때문에 자원은 낭비된다. 이것이 위의 작업 단위가 스레드일 경우 생기는 고질적인 문제점이다. 
 
구체적인 사례를 다음 코드를 통해 살펴보자.

fun main() {
  // ExecutorService 생성
  val executorService: ExecutorService = Executors.newFixedThreadPool(4)

  // 작업2 제출
  val future : Future<String> = executorService.submit<String> {
    println("작업2 시작")
    Thread.sleep(2000L) // 작업 시간 2초
    println("작업2 완료")
    "작업2 결과"
  }

  // 작업1 제출
  executorService.submit {
    println("작업1 실행")
    val result = future.get() // 작업1을 중지하고 작업2가 완료되는 것을 기다림 스레드 블로킹
    println("${result}를 가지고 나머지 작업")
  }

  // ExecutorService 종료
  executorService.shutdown()
}


이 코드는 그림3과 같이 동작한다. 


위의 그림3을 보면 스레드1에서 작업1 수행 도중 스레드2의 작업2의 결과물이 작업1을 수행하는데 필요해졌다. 그때 작업1을 실행하던 스레드1은 아무것도 하는일 없이 블로킹되며 스레드2로부터 결과를 전달받아 작업1을 재개하기 까지 많은 시간이 소요된다. 이렇게 짧은 시간동안만 블로킹되면 다행이지만, 실제 상황에서는 스레드의 성능을 반도 발휘하지 못하게 만드는 블로킹이 반복될 수 있다. 
 

그렇다면 코루틴은 기존 한계점을 어떻게 극복하는가?

코루틴은 작업 단위로, 스레드를 사용해 코루틴을 실행 할 수 있다. 하지만, 스레드 상에서 동작하는 코루틴은 언제든지 일시 중단이 가능하며, 이는 마치 스레드에 코루틴을 붙였다 땠다 할 수 있는 것과 같다. 이 때문에 코루틴은 '경량 스레드'라고도 불린다.


 도대체 경량 스레드가 무엇인가? 일시 중단 가능한 것이 무엇인가? 이건 아무리 글로 설명해도 이해가 잘 안간다. 여러 글을 읽어보았지만, 실제로 써보면서 만든 것이 코루틴을 이해나는데 훨씬 도움되었다.
 
위의 그림3의 상황을 코루틴을 이용해 해결해보자. 작업1, 작업2는 각각 코루틴1, 코루틴2로 바꾸며 코루틴3이 추가로 실행 요청되는 상황을 가정하자. 

  1. 코루틴1이 생성돼 스레드1에 실행 요청되고, 코루틴2가 생성돼 스레드2에 실행 요청된다. 스레드1에서 코루틴1 실행 도중 나머지 연산에 코루틴2로부터의 결과가 필요해진다. 하지만, 코루틴2의 작업이 끝나지 않아 코루틴1의 작업을 마저할 수 없다. 이때 코루틴1은 스레드1을 블로킹 하는 대신 사용 권한을 양보하고 다른 코루틴이 스레드 위에서 실행될 수 있도록 한다.
  2. 코루틴 3이 추가로 요청되면, 코루틴3은 자유로워진 스레드1 위에서 실행된다.
  3. 코루틴 3의 실행을 마치면 스레드1 사용 권한을 반납한다.
  4. 이후 스레드2에서 실행되면 코루틴2의 작업이 종료되고 결과를 반환한다. 그러면 코루틴1은 할당 받은 작업이 없는 스레드1 혹은 스레드2를 사용해 실행된다.(그림5에서는 코루틴1이 스레드1을 사용해 재개되는 것을 가정한다.)

즉, 코루틴은 스레드가 필요 없을 때 스레드의 사용 권한을 양보한다. 이를 통해 스레드를 블로킹 하는 상황이 줄어 각 스레드를 최대한 활용할 수 있다. 스레드는 비용이 매우 큰 객체a이다. 코루틴은 스레드가 필요 없어지면, 스레드를 양보하는 방식으로 스레드 사용을 최적화 한다.

이를 정리하면 다음과 같다.

코루틴은 스레드 안에서 실행되는 일시 중단 가능한 작업의 단위이다. 하나의 스레드에서 여러 코루틴이 서로 스레드를 양보해가며 실행될 수 있다.