본문 바로가기
자바와 코틀린

코루틴 - 구조화된 동시성

by pius712 2024. 9. 17.

구조화된 동시성

구조화된 동시성이란?

코루틴은 기본적으로 특정 코루틴 스코프 내에서 여러 코루틴이 협력하여 동작한다.

코루틴은 부모-자식 관계를 가지며, 이 부모 자식 관계가 구조화 된다. 이를 구조화된 동시성이라고 한다.

여기서 부모 코루틴이라 함은, 코루틴을 생성한 코루틴이라고 할 수 있다.

runBlocking 과 같은 코루틴은 스스로 코루틴 스코프를 만들어서 실행하므로, 본인이 루트 코루틴이 된다.

 

왜 코루틴은 이런 구조화된 동시성을 필요로 할까?

그것은 바로 코루틴을 효과적으로 제어하기 위함이다.

구조화된 동시성의 특징을 살펴보자.

  • 부모의 실행환경을 상속받는다.
  • 부모 코루틴의 취소는 자식에게 전파된다.
  • 부모 코루틴은 자식 코루틴이 끝날때까지 기다린다.

아래에서 각 특징들에 대해 각각 살펴보도록 하자.

부모의 실행환경을 상속받는다.

부모의 실행환경은 무엇을 의미할까? 그리고 상속을 받는다는 것은 어떤 의미일까?

부모의 실행환경은 CoroutineContext 를 의미한다. 즉, 코루틴이 실행될 수 있는 환경을 의미한다.

그리고 상속을 받는다는 것은 부모 CoroutineContext의 Element 들을 가지는 CoroutineContext 를 자식 코루틴의 실행환경으로 사용한다는 의미이다.

아래 코드와 다이어그램을 살펴보자

fun main() = runBlocking { // coroutine #1
	launch { // coroutine #1
		// Job 
	}
}

위 runBlocking 으로 생성된 코루틴의 CoroutineContext 를 launch 가 상속을 받는다.

이 과정에서 자식 코루틴인 luanch 는 자신의 Job 을 병합한다.

 

즉, 부모 코루틴 컨텍스트를 상속하지만 Job 은 상속받지 않으며, 아래 그림처럼 Job 이 서로를 참조하게 된다.

 

이를 확인하기 위해서 coroutineContext[Job].parent 와 coroutineContext[Job].childrent 필드를 통해 확인할 수 있다.

fun main() = runBlocking {
    val parentJob = coroutineContext[Job]
    launch {
        println(parentJob === coroutineContext[Job]?.parent)
        println(parentJob?.children?.any { it == coroutineContext[Job] })
    }

    println()
}
>> true
>> true

부모 코루틴의 취소는 자식에게 전파된다.

코루틴은 취소될 수 있는데, 취소는 자식으로 전파된다. 이 경우 취소는 자식 방향으로만 전파가 된다.

부모 코루틴이 취소되었음에도, 자식 코루틴이 지속적으로 실행된다면 리소스가 낭비되기 때문에 취소가 전파된다.

간단한 예제를 살펴보자.

fun main() = runBlocking {
    val job = launch {
        println("launch1 start")
        launch {
            delay(1000)
            println("nested launch1 end")
        }
        launch {
            delay(1000)
            println("nested launch2 end")
        }
        delay(500)
        println("launch1 end")
    }
    delay(50)
    job.cancel()
}
>> launch1 start
// 종료

부모 코루틴은 자식 코루틴이 끝날때까지 기다린다.

코루틴의 상태 중 Completing 상태가 있다. 코루틴의 상태와 관련된 내용은 아래 포스팅에서 다루고 있다.

https://pius712.tistory.com/43

 

코루틴 - 코루틴 빌더와 Job

코루틴 상태와 JobJob모든 코루틴 빌더는 Job 객체를 생성하고 반환한다.Job 객체는 코루틴 실행을 추상화한 객체로, Job 을 통해 코루틴의 상태를 추적 및 실행을 제어할 수 있다.코루틴 상태코루틴

pius712.tistory.com

 

completing 상태란, 부모 코루틴이 실행이 완료되었으나 자식 코루틴의 실행이 완료될때까지 가디리는 상황이다.

참고로 Job 의 상태 필드를 통해서 active 와 completing 을 구분할 수는 없다.

이 둘 모두 아래의 상태를 가진다.

  • active : true
  • canceld : false
  • completed : false

CoroutineScope 와 CoroutineContext

참고로 코루틴에 대해 학습을 하다보면 코루틴과 CoroutineScope, CoroutineContext 사이의 관계에 대해 잘 이해가 안되는 순간들이 있다.

이것은 구조화된 동시성의 기준을 코루틴 자체라고 착각해서 발생한다.

예를들어 아래의 예시가 이런 혼란을 만드는 것 같다.

suspend fun main() = coroutineScope {
    println(Thread.currentThread().name)
    launch {
        println(Thread.currentThread().name)
    }

    launch {
        println(Thread.currentThread().name)
    }
    Unit
}

이 코드는 코루틴이 몇개일까? 구조화된 동시성은 어떻게 되는가? launch 의 부모는 누구인가?

아래에서 추가적으로 살펴보겠지만 이 경우 코루틴은 3개이다.

  • main 이 코틀린에 의해 코루틴으로 시작 (1개)
  • launch 코루틴 (2개)

구조화된 동시성의 계층 구조

CoroutineScope
├── 첫 번째 `launch` 코루틴 (부모: `coroutineScope`의 `Job`)
└── 두 번째 `launch` 코루틴 (부모: `coroutineScope`의 `Job`)

main 함수의 coroutine 은 EmptyCoroutineContext 에서 실행된다.

따라서, 코루틴은 맞지만 메인 함수내에서 다른 코루틴과 부모 관계를 이루지 않는다.

반면, coroutineContext 는 코루틴은 아니지만, Job 객체를 통해 launch 와 부모 자식 관계를 이룬다.

애시당초 main 코루틴은 Job이 없기 때문에 구조화되지 않는다.

중단함수와 마찬가지로 CoroutineScope 와 CoroutineContext 그 자체는 코루틴이 아니다.

CoroutineContext 에서 코루틴 빌더를 통해 코루틴을 시작해야 그것이 코루틴이 되는 것이다.

그리고 CoroutineContext 를 제공해주는 범위를 CoroutineScope 를 통해 제한하는 것이다.

CoroutineScope

코루틴 스코프는 코루틴이 사용되는 영역이자, 코루틴 컨텍스트가 유지되는 영역이다.

즉, 코루틴 스코프는 코루틴이 동작하기 위해 필요한 코루틴 컨텍스트가 제공하며, 코루틴 컨텍스트를 관리하는 역할을 한다.

launch, async 와 같은 코루틴 빌더는 CoroutineScope 의 확장함수로, 코루틴 빌더가 실행되기 위해서는 CoroutineScope 를 필요로한다.

예를들어, runBlocking 의 block 함수가 CoroutineScope 내에서 실행되기 때문이다.

구조화된 동시성은 무엇을 기준으로 관계를 이루는가?

위에서는 부모-자식 관계를 코루틴을 중심으로 설명하였다.

하지만 정확히 말하자면, 구조화된 동시성에서 부모-자식 관계는 Job 을 기준으로 정의된다.

이를 확인하기 위해 코루틴 스코프 내에서 coroutineContext[Job] 을 통해 디버깅해보기를 추천한다.

아래에서는 여러가지 예시를 통해 구조화된 동시성에 대해서 살펴보고자한다.

예시 1. coroutineScope

아래 코드를 보자. launch 의 부모는 runBlocking 일까?

suspend fun main() = runBlocking {
  coroutineScope {
      launch {
          println("launch 1")
      }
      launch {
          println("launch 2")
      }
  }
}

그렇지 않다. 이를 그림으로 살펴보자.

위 코드는 아래의 그림처럼 구조화된다. coroutineScope 는 scope 를 생성하며, CoroutineContext 에 자신의 Job 가진다.

예시2. Job

아래처럼 Job 객체를 생성한다음, launch 에 job 을 context 로 제공해보자.

그러면, launch 의 부모가 job 객체가 됨을 확인할 수 있다.

fun main = runBlocking {
	val job = Job()
	launch(CoroutineName("launch") + job) {
		println(coroutineContext[Job]?.parent === job)
		// >> true
	}	
}

 

예시3. 별도의 스코프

fun main() = runBlocking {
	launch {
		val job = coroutineContext[Job]
		CoroutineScope(Dispatchers.IO).launch {
			println(coroutineContext[Job]?.parent === job)
			// >> false
		}
	}
}

위 코드는 아래와 같은 구조를 가지게 된다.

 

예시4. Job 이 없는 CoroutineScope

아래 코드의 경우, CustomScope 가 Job 을 가지고 있지 않다.

그렇기 때문에, launch 의 경우 부모 자식관계가 생기지 않으며 구조화된 동시성을 이룰 수 없게 된다.

fun main() = runBlocking {
    val coroutineScope = CustomScope()
    val job = coroutineScope.coroutineContext[Job]
    coroutineScope.launch {
        println(job)
        println(coroutineContext[Job]?.parent)
    }
    Unit
}

class CustomScope : CoroutineScope {
    override val coroutineContext: CoroutineContext 
	    = newSingleThreadContext("CustomScope")
}