코루틴 공식 가이드 자세히 읽기 — Part 5 — Dive 3
threadLocal.asContextElement() 확장 함수는 어떻게 구현되어 있을까?
Korean [English]
(목차로 돌아가기)
앞서 Part5 공식가이드 에서 우리는 코루틴에서의 ThreadLocal 구현에 대해 알아보았습니다. 여기서는 먼저 ThreadLocal 이 무엇인지, 어떤 경우 주로 사용하는지를 간단히 알아본 후에 코루틴에서 ThreadLocal 이 어떻게 구현되어 있는지 살펴보겠습니다.
ThreadLocal 이란?
ThreadLocal 은 무엇이며 왜 코루틴 가이드에서 그 처리 방법에 대해 다루고 있을까요? ThreadLocal 에 대해 검색해보면 주로 다음과 같은 정의를 찾을 수 있습니다.
It enables you to create variables that can only be read and write by the same thread. If two threads are executing the same code and that code has a reference to a ThreadLocal variable then the two threads can't see the local variable of each other.
ThreadLocal 은 동일한 스레드에서만 읽고 쓸 수 있으며 서로 다른 스레드 간에는 볼 수 없는 값을 나타냅니다.
예를들어 우리에게 모든 스레드들이 접근할 수 있는 전역 공간에 Int 형 변수가 하나 있다고 가정해 봅시다.
한 스레드가 이 변수의 값을 수정하면 다른 스레드에서도 수정된 값을 확인할 수 있습니다 (물론 이 방식은 Thread-safe 하지 않아 동기화 이슈를 발생시킵니다.) 이 변수를 ThreadLocal<Int> 타입으로 정의하여 Int 값을 wrapping 하면 이제 이 변수 값의 변경은 값을 변경하는 스레드에 국한되게 됩니다.
이를 확인해보기 위해 다음의 예를 살펴봅시다.
전역 값으로 정수형 자료를 다루는 ThreadLocal 이 정의되어 있고 main 스레드에서 ThreadLocal 값을 -1로 변경합니다. 이 후, 두 개의 스레드를 생성하여 하나는 값을 1로 다른 하나는 값을 2로 변경합니다. main 스레드는 이 두 스레드가 종료된 후 ThreadLocal 의 값을 출력하며 종료합니다. 실행 결과는 다음과 같습니다.
Main before set : 0
Main after set : -1
Thread1 before set : 0
Thread1 after set : 1
Thread2 before set : 0
Thread2 after set : 2
Main after threads : -1
실행 결과는 앞서 이야기 한 것과 같이 특정 스레드(main 포함)에서의 변경은 해당 스레드 내에서만 유효함을 확인할 수 있습니다.
ThreadLocal Usecase
우리는 이러한 특성을 갖는 ThreadLocal 을 어떤 경우 유용하게 사용할 수 있을까요? 다양한 경우가 있겠지만 다음의 두 가지 경우를 주로 생각해 볼 수 있습니다.
Case#1
생성 및 초기화가 무거운 오브젝트의 스레드 레벨 오브젝트
어떤 클래스가 Thread-safe 하지 않아 스레드 별로 오브젝트 생성이 필요하며, 오브젝트 생성 시 메모리 공간을 많이 차지하거나 초기화를 위해 긴 시간이 필요한 무거운 오브젝트라면 ThreadLocal 사용을 고려해 볼 수 있습니다.
Case#2
스레드의 실행 컨텍스트 정의
스레드 실행 로직에서 함수들이 공통적으로 접근할 수 있는 컨텍스트 요소를 정의할 때 사용할 수 있습니다. 스레드 생성 시 컨텍스트 요소들을 초기화하고 이후 호출 스택 상의 함수들에서 자유롭게 컨텍스트 요소에 접근하여 이용할 수 있게 만들 수 있습니다. 이를 통해 각 함수에 불필요한 파라미터의 추가를 피할 수 있습니다.
! 주의 !
어느 경우든 ThreadLocal을 사용 할 경우 해당 스레드들이 스레드 풀의 워커 스레드로 운용 되며 재사용 가능성이 있다면 ThreadLocal 값의 설정 및 해제에 주의를 기울여 메로리 누수를 방지해야 합니다.
코루틴과 ThreadLocal
우리는 앞선 Dive 에서 코루틴과 스레드의 관계에 대해 알아본 적이 있습니다. 요약하면 코루틴은 스레드 상에서 실행되는 자체 호출 스택을 갖는 태스크(경량 스레드)라고 할 수 있습니다.
위 이미지는 프로세스 위에서 스레드들이, 스레드들 위에서 코루틴들이 동작함을 나타내며 각 스레드에 ThreadLocal 을 표시하였습니다. 코루틴은 임의의 스레드 위에서 동작합니다. 하지만 지금까지 알아본 것과 같이 코루틴이 특정 스레드에 한정된다고 말할 수는 없습니다 (ex> confined dispatcher). ThreadLocal 은 Thread 에 한정되는 값 이기 때문에 동일한 스레드 위에서 실행되는 코루틴들이 ThreadLocal 에 접근한다면 이 값을 공유하게 됩니다.
다음 예제는 전역 공간에 ThreadLocal<String>을 정의하고 기본 값으로 “Default” 를 지정합니다. 그 후, 두 개의 코루틴을 순차적으로 실행하여 각 코루틴에서 ThreadLocal 의 값을 확인합니다.
위 코드의 실행 결과는 다음과 같습니다.
Launch1 before set : ThreadLocal : Default
Launch1 after set : ThreadLocal : Launch1
Launch2 before set : ThreadLocal : Launch1
Launch2 after set : ThreadLocal : Launch2
첫번째 Launch { block } 이 실행되면서 globalVariable 의 값을 Launch1
으로 변경하였고, 두번째 Launch { block } 이 실행되면서 앞서 변경한 값을 읽고 있습니다.
이러한 특성은 다음의 예제를 통해서도 확인해 볼 수 있습니다.
기본적으로 코루틴은 실행 중에 다른 중단함수를 호출하고 중단(suspend) 된 후에 다시 재개(resume) 될 때 스레드가 변경되었다면 다시 실행 중이던 스레드로 돌아와 작업을 재개합니다. 하지만 이번 예제에서와 같이 Dispatchers.Unconfined
를 디스패쳐로 사용하면 중단 된 후 재개 될 때 스레드가 변경 되었어도 디스패치 하지 않고 현재 스레드에서 실행을 재개하게 됩니다. 위 예제에서 delay()
중단함수는 DefaultDispatcher(IO dispatcher) 에서 지연 동작을 실행하기 때문에 delay() 함수 이후에 코루틴이 재개되어 출력한 로그에서 스레드 명은 DefaultDispatcher 로 출력되고 있습니다.
또한 중요한 부분은 동일한 코루틴임에도 delay()
함수 호출로 인해서 스레드가 변경됨에 따라 기존에 설정한 ThreadLocal 값 “Confined” 가 아닌 기본 값 “Deault” 를 출력하는 부분입니다.
Thread main, before set, ThreadLocal : Default
Thread main, after set, ThreadLocal : Confined
Thread kotlinx.coroutines.DefaultExecutor, after delay, ThreadLocal : Default
CoroutineLocal
앞서 살펴보았던 그림을 다시 한번 살펴봅시다.
코루틴을 나타내는 사각형에 CoroutineLocal 이라는 것이 있습니다. 사실 이런 용어는 없지만 이번에 우리가 알아보고자 하는 threadLocal.asContextElement()
의 특성이 마치 ThreadLocal 과 유사하게 임의의 코루틴 내부에서만 사용되는 값을 생성하는 동작을 수행하기 때문에 CoroutineLocal 이라고 이름 붙여 보았습니다.
앞서 ThreadLocal 이란 무엇이며 주로 어떠한 경우 사용하는지 살펴보았습니다. ThreadLocal 은 thread local storage 라는 메모리 공간을 데이터 저장소로 사용합니다. CoroutineLocal 또한 coroutine local storage 라는 메모리 공간을 데이터 저장소로 사용합니다. 농담입니다.🤘 사실 cotoutine local storage 는 CoroutineContext 입니다. 😅
코루틴 생성시에 threadLocal.asContextElement()
를 통해 ThreadLocal 데이터가 CoroutineContext 요소로 등록되면 코루틴이 재개(resume) 될 때 ThreadLocal 데이터에 CoroutineLocal 데이터를 써 넣고, 코루틴이 중단(suspend) 될 때 ThreadLocal 데이터를 원래 데이터로 복원합니다. 다음 예를 살펴봅시다.
이전과 같이 globalVariable 은 “Default” 라는 문자열 값을 갖는 ThreadLocal 입니다. 이를 launch {}
코루틴 빌더로 생성한 코루틴에서 접근하여 코드와 같이 로그를 출력해보면 다음과 같이 출력됩니다.
Thread main, before set, ThreadLocal : Default
Thread main, after set, ThreadLocal : Launch
Thread main, after launch block, ThreadLocal : Launch
결과를 살펴보면 launch
로 생성된 코루틴에서 변경한 ThreadLocal 값이 코루틴 종료 후에도 유효함을 확인할 수 있습니다. (CoroutineLocal 이 아니라 ThreadLocal 이므로 당연합니다.)
자, 이제 위 코드에서 ThreadLocal 을 CoroutineLocal 로 변경하는 예제를 살펴봅시다.
이전 코드와 차이점은 launch
코루틴 빌더로 코루틴 생성 시 globalVariable.asContextElement()
를 통해 ThreadLocal 을 코루틴의 컨텍스트 요소로 등록해 준 부분입니다. 이 코드의 실행 결과는 다음과 같습니다.
Thread main, before set, ThreadLocal : Default
Thread main, after set, ThreadLocal : Launch
Thread main, after launch block, ThreadLocal : Default
결과를 보면 코루틴이 종료된 후에는 ThreadLocal 값이 다시 기본 값으로 복원되었음을 알 수 있습니다.
In to the code level
자, 이제 내부에서 무슨일이 일어나는지 한번 살펴봅시다.
우선 ThreadLocal.asContextElement()
호출 시 무슨일이 일어나고 있는걸까요? 이 함수는 코루틴에서 정의한 ThreadLocal 의 확장함수 입니다.
함수를 호출하면 ThreadContextElement 를 반환하는데 실제 타입은 ThreadLocalElement 입니다. 이 가이드의 Overview 에서 코루틴 클래스 간의 관계를 다이어그램으로 살펴본 적이 있습니다. 여기서 CoroutineContext.Element
를 구현하는 클래스 중 ThreadContextElement 는 누락되어있습니다. 누락된 부분을 채우면 아래와 같으면 파란색으로 표시되어 있습니다.
앞서 살펴본 asContextElement() 확장함수는 ThreadLocal 을 ThreadLocalElement 로 변환하여 코루틴 컨텍스트에 저장합니다. 이를 통해 코루틴의 중단 / 재개 타이밍에 맞추어 CoroutineLocal 값을 ThreadLocal 에 저장 / 원래 값으로 복원을 수행합니다.
위 다이어그램에서 한가지 더 눈여겨볼 부분은 ThreadContextElement 와 CoroutineId 사이의 파란선입니다. 잘못 칠한게 아니에요😎. CoroutineId 는AbstractCoroutineContextElement 를 상속하면서 ThreadContextElement 를 구현하고 있습니다. CoroutineId 컨텍스트 요소는 코루틴의 중단 / 재개 타이밍에 맞추어 현재 스레드의 이름을 업데이트(스레드명 + 코루틴 이름) 했다가 복원합니다. 이를 통해 우리는 디버그 옵션(-Dkotlinx.coroutines.debug)으로 프로그램을 시작하면 Thread.name 에서 코루틴 이름까지 확인할 수 있게 됩니다.
이제 ThreadLocalElement 의 구현을 살펴볼까요?
ThreadLocalElement 는 CoroutineLocal 값 value
와 참조하고 있는 ThreadLocal 값 threadLocal
을 멤버로 갖습니다. 또한 모든 컨텍스트 요소들은 컨텍스트 테이블에 등록되기 위한 Key 를 갖는데 ThreadLocalElement 는 ThreadLocalKey 데이터 클래스를 키로 사용합니다.
updateThreadContext() 메서드에서는 현재 threadLocal 의 값을 oldState 로 백업 해 두고 threadLocal 에 CoroutineLocal 값을 기록합니다.
restoreThreadContext() 메서드에서는 oldState 값을 받아 현재 threadLocal 값을 복원합니다.
ThreadLocal 과 비교하여 CoroutineLocal은 Coroutine Framework 에 의해서 코루틴 시작 / 종료에 맞추어 값의 설정 / 해제가 자동으로 이루어 진다는 장점이 있습니다.
또한 CoroutineLocal 은 ThreadLocal 과 유사하게 현재 코루틴 스코프 상의 함수 호출 체인에서 각 함수들이 공유할 컨텍스트를 정의하는데 사용될 수 있습니다. 다음 그림은 이러한 예를 나타냅니다.
예를들어 우리가 WorkerContext 라는 전역 값을 정의하고 이 컨텍스트의 값들은 위 그림의 Worker Coroutine 의 어느 요소에서건 접근할 수 있다고 가정해 봅시다.
우선 위와같이 간단하게 현재 사용자 아이디와 서버 URL 정도를 컨텍스트로 유지한다고 가정하고 구현합니다. asContextElement()
함수는 여러 ThreadLocal 을 한번에 등록하기 위해 편의상 생성한 함수입니다. 이제 이 클래스를 사용하는 코드는 다음과 같이 작성할 수 있습니다.
실제 코드에서는 Repository 나 DataSource 클래스 등을 생성하겠지만 여기서는 간략하게 중단함수 정의로 대체 하였습니다.
withContext { } 블록 호출 전에 우리는 WorkerContext 를 어떤 값으로 설정할지 미리 만들어 두고, withContext 로 Worker (repository) 호출 시 WorkerContext 를 함께 전달합니다. 그러면 Worker 내에서 동작하는 모든 중단함수들은 이 값을 참조할 수 있습니다. 다음은 출력 결과입니다.
[main @coroutine#2] :
Host before sync :
userId : null, endpointUrl : null[DefaultDispatcher-worker-1 @Worker#2] : Repository :
userId : TestUser1, endpointUrl : https://test.com/api/zone/gold[DefaultDispatcher-worker-1 @Worker#2] : RemoteDataSource :
userId : TestUser1, endpointUrl : https://test.com/api/zone/gold[DefaultDispatcher-worker-1 @Worker#2] : LocalDataSource :
userId : TestUser1, endpointUrl : https://test.com/api/zone/gold[main @coroutine#2] :
Host after sync :
userId : null, endpointUrl : null
출력 결과를 보면 Worker (Repository) 내의 함수들에서만 설정 된 WorkerContext 의 값을 참조 할 수 있음을 확인할 수 있습니다.
끝.