공유 상태를 사용하는 코루틴의 문제와 데이터 동기화
1. 가변 변수를 사용할 때의 문제점
- 쓰레드 간에 데이터를 전달하거나 공유된 자원을 사용하는 경우에는 가변 변수를 사용해 상태를 공유하고 업데이트해야 함
- 하지만 여러 쓰레드에서 가변 변수에 동시에 접근해 값을 변경하면 데이터의 손실 혹은 불일치로 인해 심각한 버그가 발생 가능
- 코루틴은 주로 멀티 쓰레드 환경에서 실행되기 때문에 코루틴을 사용할 때도 동일한 문제가 발생 가능
위 코드에서 결과가 예상한대로 나오지 않는 이유
1.1 메모리 가시성 문제
- 해당 문제는 쓰레드가 변수를 읽는 메모리 공간에 관한 문제로 CPU 캐시와 메인 메모리 등으로 이루어진 하드웨어의 메모리 구조와 연관되어 있음
- 쓰레드가 변수를 변경시킬 때 메인 메모리가 아닌 CPU 캐시를 사용할 경우 CPU 캐시의 값이 메인 메모리에 전파되는 데 딜레이가 생겨 CPU 캐시와 메인 메모리 간에 데이터 불일치 문제가 발생
- 따라서 다른 쓰레드에서 해당 변수를 읽을 때 변수가 변경된 것을 확인하지 못할 수 있음
1.2 경쟁 상태 (Race Condition) 문제
- 두 개의 쓰레드가 동시에 값을 읽고 업데이트시키면 같은 연산이 두 번 일어남
- ex) count 변수에 저장된 값이 1,000일 때 두 개의 쓰레드가 동시에 count 변수를 읽고 업데이트할 경우 count 변수가 1,000에서 1,001이 되는 연산이 두 번 일어나기 때문에 하나의 연산은 손실됨
2. JVM의 메모리 공간이 하드웨어 메모리 구조와 연결되는 방식
- JVM은 쓰레드마다 스택 영역이라고 불리는 메모리 공간을 가지고 있고
- 스택 영역에는 Primitive 타입의 데이터가 저장되거나 힙 영역에 저장된 객체에 대한 참조가 저장됨
- 힙 영역은 JVM 쓰레드에서 공통으로 사용되는 메모리 공간으로 객체나 배열 같은 크고 복잡한 데이터가 저장됨
- 컴퓨터는 CPU 레지스터, CPU 캐시 메모리, 메인 메모리 영역으로 구성됨
- 각 CPU는 CPU 캐시 메모리를 두며, 데이터 조회 시 공통 영역인 메인 메모리까지 가지 않고 CPU 캐시 메모리에서 데이터를 조회할 수 있도록 만들어 메모리 접근 속도를 향상함
- 하드웨어 메모리 구조는 JVM의 스택 영역과 힙 영역을 구분하지 않음
- 따라서 JVM의 스택 영역에 저장된 데이터들은 CPU 레지스터, CPU 캐시 메모리, 메인 메모리 모두에 나타날 수 있으며 힙 영역도 마찬가지
- 앞서 설명한 구조로 인해 멀티 쓰레드 환경에서 공유 상태를 사용할 때 두 가지 문제가 발생함
- 공유 상태에 대한 메모리 가시성 문제
- 공유 상태에 대한 경쟁 상태 문제
3. 공유 상태에 대한 메모리 가시성 문제와 해결 방법
- 복습하자면 공유 상태에 대한 메모리 가시성 문제란 하나의 쓰레드가 다른 쓰레드가 변경된 상태를 확인하지 못하는 것으로 서로 다른 CPU에서 실행되는 쓰레드들에서 공유 상태를 조회하고 업데이트할 때 생기는 문제
- 메모리 가시성 문제를 해결하기 위해서는 @Volatile 어노테이션을 사용하면 됨
- @Volatile 어노테이션이 설정된 변수를 읽고 쓸 때는 CPU 캐시 메모리를 사용하지 않음
- 각 쓰레드는 @Volatile이 적용된 변수의 값을 변경시키는 데 CP 캐시 메모리를 사용하지 않고 메인 메모리를 사용
- @Volatile 어노테이션을 사용해 메모리 가시성 문제를 해결할 수 있지만 멀티 쓰레드 환경에서 여러 쓰레드가 메인 메모리의 변수에 동시에 접근할 수 있는 Race Condition 문제 또한 해결해야 함
4. 공유 상태에 대한 경쟁 상태 문제와 해결 방법
- 여러 쓰레드가 동시에 하나의 값에 접근하면서 발생하는 문제를 경쟁 상태 (Race Condition)이라고 지칭
- 동시 접근을 제한하는 간단한 방법은 공유 변수의 변경 가능 지점을 임계 영역으로 만들어 동시 접근을 제한하는 것
- 코틀린에서는 코루틴에 대한 임계 영역을 만들기 위한 Mutex 객체를 제공
- Mutex 객체의 lock 일시 중단 함수가 호출되면 락이 획득되며, 이후 해당 Mutex 객체에 대해 unlock이 호출돼 락이 해제될 때까지 다른 코루틴이 해당 임계 영역에 진입할 수 없음
- 주의할 점은 Mutex 객체를 사용해 락을 획득한 후에는 꼭 해제해야 함
- 해제하지 않을 경우 임계 영역에 다른 쓰레드가 접근하지 못해 데드락이 발생할 수 있음
- ReentrantLock 대신 Mutex를 사용하는 이유는 ReentrantLock 객체에 대해 lock을 호출했을 때 블로킹이 되기 때문
- 공유 상태에 접근할 때 하나의 전용 쓰레드만 사용하도록 강제하면 공유 상태에 동시에 접근하는 문제를 해결할 수 있음
- 하나의 전용 쓰레드만 사용할 수 있도록 만드는 방법은 newSingleThreadContext 함수를 사용해 단일 쓰레드로 구성된 CoroutineDispatcher 객체를 생성해 특정 연산을 위해 사용되도록 만들면 됨
- 전용 단일 쓰레드를 할당함에 따라 경쟁 상태에 진입하지 않도록 처리
CoroutineStart의 다양한 옵션들 살펴보기
- 코루틴에 실행 옵션을 부여하기 위해 launch나 async 등의 코루틴 빌더 함수의 start 인자로 CoroutineStart 옵션을 전달 가능
- 이전 게시글에서 언급했듯이 launch의 start 인자로 CoroutineStart.LAZY를 넘기면 코루틴을 지연 시작할 수 있음
- CoroutineStart.LAZY 이외에도 다음과 같이 다양한 옵션이 있음
- CoroutineStart.DEFAULT
- CoroutineStart.ATOMIC
- CoroutineStart.UNDISPATCHED
1. CoroutineStart.DEFAULT
- launch의 start 인자로 아무런 값이 전달되지 않으면 기본 실행 옵션인 CoroutineStart.DEFAULT로 설정됨
- 코루틴 빌더의 start 인자로 CoroutineStart.DEFAULT를 사용하면 코루틴 빌더 함수를 호출한 즉시 생성된 코루틴의 실행을 CoroutineDispatcher 객체에 예약하며, 코루틴 빌더 함수를 호출한 코루틴은 계속해서 실행됨
부연 설명
- launch 함수의 start 인자로 아무 값도 넘어가지 않았으므로 CoroutineStart.DEFAULT가 적용됨
- 메인 쓰레드에서 실행되는 runBlocking 코루틴에 의해 launch 함수가 호출되면 메인 쓰레드를 사용하는 CoroutineDispatcher 객체에 launch 코루틴의 실행이 즉시 예약됨
- 하지만 runBlocking 코루틴이 메인 쓰레드를 양보하지 않고 계속해서 실행되므로 launch 코루틴은 실행되지 못하며, runBlocking 코루틴에 의해 작업2가 출력되고 나서야 메인 쓰레드가 자유로워져 launch 코루틴이 실행됨
- 쓰레드를 양보하기 전까지 쓰레드를 점유하는 코루틴의 특성과 양보받은 쓰레드를 사용해 실행되는 코루틴의 특성을 잘 나타내는 코드 결과
2. CoroutineStart.ATOMIC
- 코루틴이 실행 요청됐지만 CoroutineDispatcher 객체가 사용할 수 있는 쓰레드가 모두 작업 중이어서 쓰레드로 보내지지 않는 경우 생성 상태에 머무므로 이를 `실행 대기 상태`라고 지칭
- 일반적인 코루틴의 경우 `실행 대기 상태`일 때 취소되면 실행되지 않고 종료됨
- 하지만 launch 함수의 start 인자로 CoroutineStart.ATOMIC 옵션을 적용하면 해당 옵션이 적용된 코루틴은 실행 대기 상태에서 취소되지 않음
부연 설명
- 코드 실행 결과를 보면 launch 코루틴이 취소되지 않아 작업2와 작업1이 모두 출력된 것을 확인 가능
- 정리하면 CoroutineStart.ATOMIC 옵션은 코루틴의 실행 대기 상태에서 취소를 방지하기 위한 옵션
3. CoroutineStart.UNDISPATCHED
- 일반적인 코루틴은 실행이 요청되면 CoroutineDispatcher 객체의 작업 대기열에서 대기하다가 CoroutineDispatcher 객체에 의해 쓰레드에 할당되어 실행됨
- 하지만 CoroutineStart,UNDISPATCHED 옵션이 적용된 코루틴은 CoroutineDispatcher 객체의 작업 대기열을 거치지 않고 호출자의 쓰레드에서 즉시 실행됨
부연 설명
- CoroutineStart.UNDISPATCHED 옵션으로 launch 함수를 호출하면 launch 코루틴은 즉시 호출자의 쓰레드인 메인 쓰레드에 할당되어 실행됨
- 따라서 코드의 실행 결과를 보면 launch 함수를 호출한 즉시 launch 코루틴이 메인 쓰레드를 점유해 작업1이 먼저 실행되고, 이후 작업2가 실행되는 것을 확인 가능
- 정리하면 CoroutineStart.UNDISPATCHED가 적용된 코루틴은 CoroutineDispatcher 객체의 작업 대기열을 거치지 않고 곧바로 호출자의 쓰레드에 할당되어 실행됨
- 단, 처음 코루틴 빌더가 호출됐을 때만 CoroutineDispatcher 객체를 거치지 않고 실행됨
- 만약 코루틴 내부에서 일시 중단 후 재개되면 CoroutineDispatcher 객체를 거쳐 실행됨
무제한 디스패처 (Unconfined Dispatcher)
- 무제한 디스패처란 코루틴을 자신을 실행시킨 쓰레드에서 즉시 실행하도록 만드는 디스패처
- 이때 호출된 쓰레드가 무엇이든지 상관없기 때문에 실행 쓰레드가 제한되지 않으므로 무제한 디스패처라는 이름이 붙임
부연 설명
- launch 코루틴 빌더 함수는 코루틴을 Dispatchers.Unconfined를 사용해 실행
- 이때 launch 코루틴 빌더 함수를 호출하는 쓰레드는 메인 쓰레드이므로 Dispatchers.Unconfined를 사용해 실행되는 launch 코루틴은 자신을 실행시킨 메인 쓰레드에서 실행됨
- 이처럼 무제한 디스패처를 통해 실행된 코루틴은 자신을 실행시킨 쓰레드에서 즉시 실행됨
1. 무제한 디스패처의 특징
1.1 코루틴이 자신을 생성한 쓰레드에서 즉시 실행된다
부연 설명
- runBlocking 코루틴은 Dispatchers.IO의 공유 쓰레드 풀의 쓰레드 중 하나를 사용해 실행되고, 그 내부에서 실행되는 launch 코루틴은 runBlocking 코루틴이 사용하던 쓰레드를 그대로 사용해 실행됨
- runBlocking 코루틴이 사용하는 쓰레드와 launch 코루틴이 사용하는 쓰레드가 DefaultDispatcher-worker-1으로 같은 것을 확인 가능
- 이처럼 무제한 디스패처에서 코루틴이 실행되면 코루틴 빌더를 호출한 쓰레드에서 즉시 코루틴이 실행됨
- 코루틴에 CorotuineStart.UNDISPATCHED 옵션을 적용했을 때의 동작과 매우 비슷
1.2 중단 시점 이후의 재개는 코루틴을 재개하는 쓰레드에서 한다
- 무제한 디스패처를 사용해 실행되는 코루틴은 자신을 실행시킨 쓰레드에서 쓰레드 스위칭 없이 즉시 실행되지만 일시 중단 전까지만 자신을 실행시킨 쓰레드에서 실행됨
- 만약 무제한 디스패처를 사용하는 코루틴이 일시 중단 후 재개된다면 자신을 재개시키는 쓰레드에서 실행됨
부연 설명
- 일시 중단 전에는 launch 함수를 호출한 메인 쓰레드에서 코루틴이 실행되는 것을 볼 수 있음
- 하지만 일시 중단 후 재개될 때는 DefaultExecutor라고 불리는 생소한 쓰레드에서 실행되고 있음
- DefaultExecutor 쓰레드는 delay 함수를 실행하는 쓰레드로 delay 함수가 일시 중단을 종료하고 코루틴을 재개할 때 사용하는 쓰레드
- 즉, 재개 이후의 launch 코루틴은 자신을 재개시킨 쓰레드인 DefaultExecutor를 사용하게 됨
- 이처럼 무제한 디스패처를 사용하는 경우 어떤 쓰레드가 코루틴을 재개시키는지 예측하기 매우 어렵기 때문에 일반적인 상황에서 무제한 디스패처를 사용하면 비동기 작업이 불안정 (테스트 등의 특수 상황에서만 권장)
코루틴의 동작 방식과 Continuation
1. Continuation Passing Style
- 일반적으로 코드가 실행될 때는 코드 라인이 순서대로 실행되는 방식으로 동작하지만 코루틴은 코드를 실행하는 도중 일시 중단하고 다른 작업으로 전환한 후 필요한 시점에 다시 실행을 재개하는 기능을 지원
- 코루틴이 일시 중단하고 재개하기 위해서는 코루틴의 실행 정보가 어딘가에 저장돼 전달해야 함
- 코틀린은 코루틴의 실행 정보를 저장하고 전달하는 데 CPS(Continuation Passiing Style)라고 불리는 프로그래밍 방식 채택
- CPS는 `Continuation을 전달하는 스타일`이라는 뜻으로 여기서 Continuation은 이어서 실행해야 하는 작업을 나타냄
- CPS를 채택한 코루틴은 코루틴에서 이어서 실행해야 하는 작업 전달을 위해 Continuation 객체를 제공
- Continuation 객체는 코루틴의 일시 중단 시점에 코루틴의 실행 상태를 저장하며, 여기에는 다음에 실행해야 할 작업에 대한 정보가 포함됨
- 따라서 Continuation 객체를 사용하면 코루틴 재개 시 코루틴의 상태를 복원하고 이어서 작업을 진행할 수 있음
- 이처럼 Continuation 객체는 코루틴의 실행에 매우 핵심적인 역할을 수행
- 코루틴 라이브러리에서 제공하는 고수준 API는 Continuation 객체를 캡슐화해 사용자에게 노출하지 않지만 내부적으로는 코루틴의 일시 중단과 재개가 Continuation 객체를 통해 이루어짐
2. 코루틴의 일시 중단과 재개로 알아보는 Continuation
- 코루틴에서 일시 중단이 일어나면 Continuation 객체에 실행 정보가 저장되며, 일시 중단된 코루틴은 Continuation 객체에 대해 resume 함수가 호출돼야 재개됨
부연 설명
- suspendCancellableCoroutine 함수가 호출되면 runBlocking 코루틴은 일시 중단되며, 실행 정보가 Continuation 객체에 저장돼 suspendCancellableCoroutine 함수의 람다식에서 CancellableContinuation 타입의 수신 객체로 제공됨
- Continuation 객체에 대해 재개가 호출되지 않아 runBlocking 코루틴이 재개되지 못했기 때문에 코드가 실행 종료되지 않음
- runBlocking 코루틴을 재개시키기 위해서는 다음과 같이 Continuation 객체에 대해 resume 함수를 호출해야 함
3. 다른 작업으로부터 결과 수신해 코루틴 재개하기
- 코루틴 재개 시 다른 작업으로부터 결과를 수신받아야 하는 경우에는 suspendCancellableCoroutine 함수의 타입 인자에 결과로 반환받는 타입을 입력하면 됨
부연 설명
- thread 함수가 새로운 쓰레드에서 코드 블록이 실행되도록 만들어 1초간 대기 후 continuation에 대한 resume을 `실행 결과`와 함께 호출하면 `실행 결과`는 result에 할당되고 runBlocking 코루틴이 재개됨
참고
코틀린 코루틴의 정석 (조세영 저)
반응형
'Kotlin > 코틀린 코루틴의 정석' 카테고리의 다른 글
[Kotlin 코루틴] 코루틴의 이해 (0) | 2024.11.26 |
---|---|
[Kotlin 코루틴] 일시 중단 함수 (0) | 2024.11.22 |
[Kotlin 코루틴] 예외 처리 (0) | 2024.11.22 |
[Kotlin 코루틴] 구조화된 동시성 (0) | 2024.11.21 |
[Kotlin 코루틴] CoroutineContext 정리 (0) | 2024.11.19 |