brunch

You can make anything
by writing

C.S.Lewis

by 서준수 Dec 12. 2024

코루틴 공유 자원 관리

코틀린 코루틴(13)

코루틴 공유 자원 관리


스레드와 마찬가지로 코루틴도 비동기로 동작하기 때문에 공유 자원 관리에 대한 고민이 필요하다. 다시 말하면 공유 자원 관리가 코루틴만의 특별한 요소가 아니라는 것이다.


JVM 스레드 공유 자원

JVM 메모리 구조를 기준으로 스레드가 공유하는 자원은 메서드 영역(Method Area)과 힙 영역(Heap Area)이다. 메서드 영역은 클래스 정의, 메서드 정보, 정적 변수 등이 저장된다. 힙 영역에는 객체와 배열이 저장되고 가비지 컬렉터에 의해 관리된다. 나머지 영역은 스레드마다 독립적으로 존재한다.


비동기 상황에서 공유 자원의 문제점

두 스레드가 함께 공유 자원을 사용할 때 원자성이 보장되지 않으면 문제가 발생할 수 있다. 원자성이 뭔지 몰라도 상관없다. 일단 다음 코드를 보고 실행 결과를 예측해 보자.

아마 공유 자원에 대한 문제점을 경험하지 못한 사람이라면 number의 값이 2000이라고 생각할 수 있다. 물론 2000인 경우도 있지만 2000보다 작은 수일 경우가 더 많을 것이다.


실행 결과는 다음과 같다. (알다시피 매번 바뀐다.)

Final counter value: 1587


원자성이 뭔데?

원자성이 보장되지 않으면 발생할 수 있는 문제라고 했는데 그럼 원자성 뭘까? 원자성은 더 이상 쪼개질 수 없는 성질을 말한다. 프로그래밍에서는 어떤 작업을 쪼갤 수 없다고 생각하면 된다. 따라서 원자성을 보장한다는 것은 작업(연산)이 중간에 끊기지 않고 모두 완료되거나 아니면 아예 실행되지 않아야 함을 의미한다. 부분적인 실행과 같은 중간 상태는 존재하지 않는다. number가 증가되는 연산은 다음과 같이 나눌 수 있다.

1. 현재 number 값을 읽는다.
2. number 값을 1 증가시킨다.
3. 증가된 값을 number에 반영한다.

이 과정이 원자성을 가지지 않으면 연산 과정에서 데이터 경합이 발생한다. 예를 들어, 두 스레드가 거의 동시에 number의 값을 읽으면 같은 값을 읽게 된다. 그 후 각 스레드가 1 증가시킨 후 그 값을 다시 number에 쓸 수 있다. 그러면 number의 초깃값이 0이었다면 증가된 값은 2가 아니라 1이 된다. 이 과정을 그림으로 나타내면 다음과 같다.

하나의 스레드가 값을 1 증가시킨 후 그 값을 다시 number에 쓰기 전에 다른 스레드가 number의 값을 읽어서 발생한 문제다. 원자성을 가졌다면 위 3단계가 하나의 작업으로 완료 후에 다른 작업이 시작되어야 한다. 하지만 이 경우에는 작업 도중에 다른 스레드가 이전 작업에 끼어들었다. 이것은 작업이 완료되지 않은 부분적인 실행 상태에서 방해를 받은 것이다. 원자성이 보장되지 않은 것이다.


그러면 원자성을 보장할 수 있는 방법은 무엇이 있을까?

동기화 블록, 잠금, 조건 변수, 원자 변수 등이 있다. 모두 근본적으로는 하나의 작업이 완료된 후에 다음 작업이 시작되도록 제어하는 것이다. 이것은 코루틴에서도 다를 게 없다.


코루틴 공유 자원 문제

코루틴 공유 자원 관리라는 제목을 적어놓고 왜 스레드에 관한 이야기만 했을까? 스레드 내에서 동작하는 코루틴을 이해하려면 스레드에 관한 이해는 필수다. 거기에 더해 비동기 선배인 스레드가 겪어온 길이 코루틴에 귀감이 되기 때문이다. 스레드에서 이미 원자성 보장을 위한 대책을 내놓은 것이 코루틴에도 유효한 부분이 있다는 말이다. 정말인지 코루틴에서 발생하는 공유 자원 문제를 살펴보자.


다음 코드의 실행 결과는 무엇일까? 지금까지 다룬 코루틴 내용을 잘 숙지했다면 추측해 볼 수 있다.

실행 결과는 다음과 같다.

Final counter value = 1000

실행 결과 number가 1000 이하가 아니라 1000인 이유가 무엇일까? 여태까지 다룬 내용에서는 코루틴 자체의 실행 순서를 집중해서 살펴봤다. 하지만 내부에서 반복되는 경우는 깊게 고민한 적이 없었다. 그래도 학습한 내용을 잘 조합해 보면 그럴듯한 추측을 할 수 있을 것이다.


위 코드는 coroutineScope로 새로운 코루틴 스코프를 만들고 내부에서 launch로 코루틴을 실행하고 있다. 그리고 1000번 반복하며 launch를 통해 코루틴을 실행한다. 즉 1000개의 코루틴을 생성한다. 그러면 스레드에서 공유 자원 관리 시 겪은 문제점에 대해 고려해 볼 수 있다.

1. 데이터 경합이 발생했는가?
2. 원자성이 보장되는가?

일단 결과가 1000이 나왔으니 문제가 없다는 뜻이다. 스레드에서 데이터 경합이 발생한 경우를 돌이켜 보면 두 개의 스레드가 같은 자원에 접근하는 것이 문제였다. 현재 코루틴은 어떤가? 1000개의 코루틴이 같은 자원을 두고 경쟁을 하는가? 당연히 아니다.


결국 데이터 경합은 코루틴의 문제가 아니라 스레드의 문제다. 앞선 예제의 스레드와 코루틴의 구조를 그려보면 다음과 같다.

launch는 runBlocking의 컨텍스트를 상속받아 실행된다. 따라서 실행되는 스레드는 메인 스레드다. 메인 스레드에서 실행된 코루틴은 같은 스레드에서 쭉 실행된다. 그리고 해당 스레드에서 실행되는 코루틴은 순차적으로 실행된다. 결국 데이터 경합이 발생하지 않는 것이다. 확인해 보고 싶다면 launch 블록 내에

println("thread ${Thread.currentThread().name} counter = $number")와 같은 출력을 해보면 된다.


코루틴에서 데이터 경합

그러면 코루틴에서는 공유 자원 관리가 필요하지 않나?...라고 생각하진 않을 것이라 믿는다. 코루틴은 특정한 스레드에 종속되지 않는다고 했다. 그럼 방금 전 메인 스레드에서는 왜 종속되었을까? 메인 스레드, Dispatchers.Unconfined와 같은 디스패처는 예외다.


그렇다면 Dispatchers.IO, Dispatchers.Default와 같은 다중 스레드 풀에서 코루틴을 실행하는 디스패처를 사용하면 데이터 경합이 발생한다는 말이 된다. 이것을 확인하기 위해서 앞선 예제를 살짝 수정해 보자.


launch에 디스패처를 지정했다. 이제 메인 스레드에서 실행되지 않고 스레드에 종속되지도 않는 상태가 되었다.

실행 결과는 다음과 같다.

thread DefaultDispatcher-worker-1 @coroutine#2 counter = 1
thread DefaultDispatcher-worker-1 @coroutine#3 counter = 2
...(중략)...
thread DefaultDispatcher-worker-19 @coroutine#1001 counter = 995
thread DefaultDispatcher-worker-9 @coroutine#1000 counter = 994
thread DefaultDispatcher-worker-5 @coroutine#999 counter = 993
Final counter value = 995

1000개의 코루틴이 생성되었고 특정 스레드에 종속되지 않았다. 결국 데이터 경합이 발생하여 원자성이 보장되지 않아 최종 값이 995가 되었다.


코루틴의 상호 배제

그러면 원자성을 보장하려면 어떻게 해야 할까? 앞서 말했듯이 방법은 동기화 블록, 잠금, 조건 변수, 원자 변수 등이 있다. 이러한 방식의 목적은 하나의 작업 단위가 실행될 때 다른 스레드가 끼어들지 못하게 막는 것이다. (원자 변수는 조금 다른 방식이다.) 공유 자원에 접근을 제한하여 어떤 작업이 절대로 동시에 수행되지 않도록 하는 것이다. 그리고 그러한 공유 자원에 접근하는 작업 영역을 임계 구역(critical section)이라고 한다. 코드로 생각하면 공유 자원에 접근하는 코드 블록이다. 해당 코드 블록은 단 하나의 스레드 또는 코루틴이 실행할 수 있다.


동기화 블록, 잠금, 조건 변수와 같이 공유 자원에 접근을 제한(blocking)하는 방식을 상호 배제(Mutual Exclusion)이라고 한다. 보통 줄여서 뮤텍스(Mutex)라고 부른다. 그리고 뮤텍스는 이런 상호 배제를 구현하는 하나의 방식이기도 하다.


코루틴도 뮤텍스를 지원한다. 앞선 데이터 경합이 발생한 예제에 적용하면 다음과 같다.

실행 결과는 다음과 같다.

thread DefaultDispatcher-worker-1 @coroutine#2 counter = 1
thread DefaultDispatcher-worker-1 @coroutine#3 counter = 2
...(중략)...
thread DefaultDispatcher-worker-7 @coroutine#1001 counter = 999
thread DefaultDispatcher-worker-7 @coroutine#999 counter = 1000
Final counter value = 1000

뮤텍스를 사용하여 임계 구역을 생성했다. lock()을 선언하면 임계 구역이 시작되고 unlock()을 선언하면 임계 구역이 끝난다. 따라서 그 사이에 있는 number++라는 작업은 원자성이 보장된다.


위의 mutex.lock(); try { ... } finally { mutex.unlock() } 패턴은 간단하게 withLock이라는 확장 함수로 대체할 수 있다.


요약하자.

- 원자성은 특정 작업을 하나의 단위로 보는 것이다.
- 원자성을 보장한다는 것은 그 작업을 동시에 하지 못하게 하는 것이다.
- 다수의 스레드가 동시에 공유 자원을 사용할 때 원자성이 보장되지 않으면 데이터 경합이 발생한다.
- 단일 스레드에서 동작하는 코루틴은 데이터 경합이 발생하지 않는다.
- 코루틴의 데이터 경합을 방지하는 방법은 스레드에서 사용하는 방법과 다를 바 없다.
- 공유 자원에 접근하는 작업 영역을 임계 구역이라고 한다.
- 코루틴은 상호 배제를 위한 Mutex를 지원한다.
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari