[Kotlin Coroutine] 코루틴 기본 이해 & JVM에서의 async
목표
- Kotlin 코루틴이 등장한 배경과 기본 개념 소개
- Launch와 suspend 함수를 사용한 간단한 예제를 통해 코루틴의 기본 실습
JVM 프로세스와 스레드
프로그램이란?
- 프로그램은 디스크에 저장된 파일의 내용(ex. 소스코드 파일)처럼 보조 메모리에 저장된 수동적 개체(passive entity)이다.
- 실행되어야 할 몇 가지 명령들이 포함된 파일로, 실제로 수행하기 위해서는 메모리에 올려서 실행되어야 한다.
프로세스란?
- 프로세스(Job)은 CPU에 의해 실행될 수 있도록 컴퓨터 메모리에 로드된 프로그램 코드를 의미합니다.
- 프로세스는 컴퓨터에서 실행되는 프로그램의 인스턴스로도 볼 수 있습니다.
- 프로세스가 동작하기 위해서는 메모리 주소, CPU, I/O와 같은 리소스가 필요합니다.
- 프로세스는 수행되는 동안에만 존재합니다.
- 프로세스는 하위 프로세스라고 하는 다른 프로세스를 생성할 수 있고, 이 프로세스들 간에 격리되어 있어 메모리를 공유하지 않습니다.
스레드란?
- 프로세스는 여러 스레드를 가질 수 있고, 이러한 스레드들은 프로세스 내 포함되어 있어 격리되어 있지 않습니다.
결론
- 프로세스는 프로그램이 실행될 때에만 생성되는 프로그램의 하위 집합
- 프로그램은 특정 작업을 수행하도록 만들어진 명령어의 집합
JVM
JVM(Java Virtual Machine)은 컴퓨터 자원을 할당 받아 실행되는 프로세스로 자바 애플리케이션을 실행하는 런타임 엔진 역할을 합니다.
JVM 작동 방식
![](https://blog.kakaocdn.net/dn/bTbuKF/btsHRxoTq8x/0yyliIklUXSht5mjuEVAx0/img.jpg)
클래스 로더
Java 컴파일러에 의해 .java 파일을 컴파일하면 .java 파일에 있는 동일한 클래스 이름을 가진 .class 파일(바이트코드 포함)이 생성됩니다. 이 .class 파일은 클래스 로더를 통해 아래 세가지 활동을 진행합니다.
- Loading:
- .class 파일을 읽어 바이너리 데이터를 생성하고, 메소드 영역에 저장
- 힙 메모리에 .class 파일을 표시하기 위해 Class 타입의 객체를 생성(java.lang 패키지에 미리 정의된 Class 타입)
- Linking
- Verification: .class 파일이 올바른지 확인하고, 문제가 있다면 런타임 예외로 java.lang.VerifyError 발생
- Preparation: 클래스 정적 변수에 메모리를 할당하고, 메모리를 기본값으로 초기화
- Resolution: symbolic 참조를 직접 참조로 바꿈
- Initialization
- 모든 정적 변수 및 정적 블록에 값 할당
- 클래스 계층 구조의 부모 → 자식으로 실행
일반적으로 세 가지 클래스 로더가 있습니다.
- Bootstrap class loader: 모든 JVM 구현에는 신뢰할 수 있는 클래스를 로드하는 부트스트랩 클래스 로더가 있어야 함
- Extension class loader: 부트스트랩 클래스 로더의 하위 클래스로, java.ext.dirs 시스템 속성에 지정된 기타 디렉토리에 있는 클래스를 로드함
- System/Application class loader: 확장 클래스 로더 하위 개념으로, 애플리케이션 클래스 경로에서 클래스를 로드하는 역할을 함
![](https://blog.kakaocdn.net/dn/clFKip/btsHThLoZtu/zX7MebkE3xElVmsqa7lPPk/img.jpg)
- JVM은 클래스 로드하기 위해 위임 계층 원칙을 따릅니다.
- 시스템 클래스 로더는 로드 요청을 확장 클래스 로더에 위임하고, 확장 클래스 로더는 부트스트랩 클래스 로더에 위임합니다.
- 부트스트랩 경로에 클래스가 있으면 해당 클래스가 로드되고, 그게 아니라면 확장 클래스 로더로 전송된 다음 확인하는 식으로 진행됩니다.
![](https://blog.kakaocdn.net/dn/cCWefj/btsHSbyE6dL/z2EKQm5fo1gG48RfWKtex1/img.png)
JVM 메모리
![](https://blog.kakaocdn.net/dn/60Kbj/btsHSTc2CTT/k34VJgYEiddE2f7NuI7kb0/img.jpg)
- Method area:
- 메소드 영역에는 정적 변수를 포함하여 클래스 이름, 부모 클래스 이름, 메소드/변수 정보 등 모든 클래스 수준 정보가 저장됨.
- JVM당 하나의 메소드 영역만 있으며, 공유 리소스
- Heap area: 모든 객체 정보가 저장되며, 메모리 영역처럼 JVM당 하나의 힙 영역이 있어 공유 자원임
- Stack area:
- 모든 스레드에 대해 JVM은 하나의 런타임 스택을 생성함
- 이 스택의 모든 블록은 메소드 호출을 저장하는 활성화 레코드/스택 프레임이라고 함
- 스레드가 종료되면, 해당 런타임 스택은 JVM에 의해 삭제됨(공유 리소스 X)
- PC Registers:
- 스레드의 현재 실행 명령 주소를 저장함
- 각 스레드별로 분리된 PC register를 가짐
- Native method stacks: 모든 스레드에 대해 별도의 네이티브 스택이 생성되며, 네이티브 메소드 정보가 저장됨
Execution Engine
실행 엔진은 .class(바이트코드)를 실행합니다. 바이트코드를 한 줄씩 읽고 메모리 영역에 있는 데이터와 정보를 이용해 명령어를 실행합니다. 이는 아래와 같이 세 부분으로 분류됩니다.
- Interpreter: 바이트코드를 한 줄씩 해석하고 실행하며, 같은 메소드를 여러 번 호출하면 호출할때마다 해석이 필요함
- Just-In-Time Compiler(JIT):
- 인터프리터의 효율성을 높이기 위해 사용됨
- 전체 바이트코드를 컴파일하고 네이티브 코드로 변경하여 인터프리터에 제공함 → 반복 메소드 호출 시, 인터프리터 재해석이 불필요해짐
- Garbage Collector: 참조되지 않는 객체 제거
Java Native Interface(JNI)
네이티브 메소드 라이브러리와 상호작용하며 실행에 필요한 네이티브 라이브러리를 제공하는 인터페이스로, 이를 통해 JVM은 C/C++ 라이브러리를 호출할 수 있습니다.
Native Method Libraries
실행 엔진에서 필요로하는 네이티브 라이브러리(C/C++)의 모음집
일반적으로 코틀린 애플리케이션의 실행 진입점은 main 함수를 통해 만들어집니다. 애플리케이션 실행 시, JVM은 프로세스를 시작하고 main 스레드를 생성해 main 함수 내부 로직을 수행하고 더 이상 실행할 코드가 없으면 종료됩니다.
- main 스레드가 항상 프로세스의 끝을 함께 하진 않습니다.
- JVM 프로세스는 사용자 스레드가 모두 종료될 때 종료되며, main 스레드는 사용자 스레드 중 하나입니다.
- 멀티 스레드 환경에서 사용자 스레드를 여러 개 사용하고 있다면, main 스레드가 종료되어도 프로세스가 종료되지 않을 수 있습니다.
사용자 스레드와 데몬 스레드
JVM은 스레드를 사용자 스레드와 데몬 스레드로 구분한다.
- 사용자 스레드: 우선도가 높은 스레드 → 멀티 스레드 환경의 프로세스에서는 사용자 스레드가 모두 종료되면 프로세스 종료됨
- 데몬 스레드: 우선도가 낮은 스레드
- 생성한 스레드를 데몬 스레드로 바꾸고 싶다면,
isDaemon = false
로 설정 필요 - 데몬 스레드가 강제 종료되어도, 프로세스는 정상 종료
- 생성한 스레드를 데몬 스레드로 바꾸고 싶다면,
단일 스레드 한계
- 스레드는 하나의 작업을 수행할 때 다른 작업을 동시에 수행할 수 없습니다.
- 작업들이 서로 독립적이라 동시에 연산할 수 있는 작업들이 있을 때, 단일 스레드를 사용한다면 작업 속도가 느려 효율이 떨어집니다.
- 멀티 스레드를 사용하면, 독립적인 작업들에 대해 서로 다른 스레드에서 병렬 수행할 수 있어 단일 스레드보다 작업 속도가 빠릅니다.
코루틴이 등장하기 이전의 멀티 스레드 프로그래밍
Thread 클래스를 이용한 멀티 스레드 다루기
문제점
- Thread 클래스를 상속한 클래스를 인스턴스화해 신규 스레드를 생성함 → 스레드는 생성 비용이 비싸기 때문에 매번 새로운 스레드를 생성하는 것은 성능적으로 좋지 않음
- 스레드 생성과 관리에 대한 책임이 개발자에게 있음 → 프로그램 복잡성 증가, 실수에 의한 메모리 누수 가능성 ↑
스레드는 왜 생성 비용이 비싸다고 말할까?
Java 스레드 생성에는 상당한 작업이 필요하기 때문에 비용이 많이 발생합니다.
[ 필요 작업 ]
- thread stack을 위해 큰 메모리 블록이 할당/초기화되어야 함
- host OS에 native thread를 생성/등록하려면 system call이 필요함 → 컨텍스트 스위칭 필요
- JVM thread 생성 시, JVM 내부 데이터 구조에 스레드를 위한 메모리 생성/초기화/추가 되어야 함
또한, 스레드가 살아있는 동안 리소스(ex. thread stack, JVM thread 리소스, OS native thread 리소스)를 잡아두고 있기 때문에, 비용이 발생합니다.
참고
![](https://blog.kakaocdn.net/dn/de1U9L/btsHS04feHT/VYlhfDKCjKTgNTEIqebELk/img.gif)
- JavaApplication Level Thread: 개발자가 마주하는 스레드를 의미함
- Kernel Level Thread(Native Thread): OS가 관리하는 스레드
- LightWeight Process(LWP): JavaApplication Level Thread와 Kernel Lavel Thread 중간다리 역할
- OS에게 커널 레벨 스레드 관리를 요청하고, JVM 애플리케이션에서 요청한 JavaThread를 KernelThread에 매핑함
위 문제점을 해결하기 위해 스레드를 재사용할 수 있고, 스레드 관리를 기 구축된 시스템이 책임질 수 있도록 Executor 프레임워크가 만들어졌습니다.
Executor 프레임워크를 통해 스레드풀 사용하기
역할
- 작업 처리를 위한 스레드풀(스레드의 집합)을 미리 생성하고, 사용자 요청 작업을 각 스레드에 분배함
- 스레드 작업이 완료되어도, 스레드를 종료하지 않고 다른 작업에서 재사용함
Executor 프레임워크 사용을 통해, 개발자는 더 이상 스레드를 직접 다루거나 관리하지 않아도 됩니다.
개발자는 1) 스레드풀 사이즈 설정 및 2) 어떤 작업을 스레드로 실행할지만 제출하면 됩니다.
- 위 예시에서 볼 수 있듯이 Executor 프레임워크를 사용하면, 사용 가능한 스레드가 있을 때 알아서 작업을 분배해주기 때문에, 개발자는
ExecutorService
객체 내부의 동작을 신경쓰지 않아도 됩니다.
한계
- Executor 프레임워크이 가진 문제 중 대표적인 문제는 스레드 블로킹입니다.
- 스레드 블로킹(Thread Blocking): 스레드가 아무것도 하지 못하고 사용될 수 없는 상태
- 여러 스레드가 동기화 블록(synchronized)에 동시에 접근하고자 하는 경우
- 뮤텍스(mutex)나 세마포어(semaphore)로 인해 공유자원에 접근할 수 있는 스레드가 제한된 경우
- ExecutorService 객체에 제출한 작업 결과를 전달받고자 하는 경우
- 작업 결과를 기다릴 때 Future 객체를 사용하고, 미래 언제 올지 모르는 값을 기다리는
get()
함수를 호출하게 되면,get()
함수 호출한 스레드는 결과를 받을 때까지 블록킹됨
- 작업 결과를 기다릴 때 Future 객체를 사용하고, 미래 언제 올지 모르는 값을 기다리는
- 스레드 블로킹(Thread Blocking): 스레드가 아무것도 하지 못하고 사용될 수 없는 상태
- 스레드는 비싼 자원이기 때문에, 사용될 수 없는 상태에 놓이는 것이 반복되면 애플리케이션의 성능이 떨어집니다.
이후의 멀티 스레드 프로그래밍과 한계
Executor 프레임워크 등장 이후, 기존 문제를 보완하고자 여러 방법이 생겨났습니다.
CompatableFuture
: 기존Future
객체 단점을 보완해 스레드 블로킹을 줄이고 작업 체이닝하는 기능 제공ExecutorService
의submit
함수 대신CompletableFuture.supplyAsync
함수 사용supplyAsync
함수의 첫 번째 매개변수에는 실행할 코드를 넣고, 두 번째 매개변수에는 해당 작업이 실행될ExecutorService
객체 지정
- RxJava: 리액티브 프로그래밍 패러다임 지원을 통해 결과값을 데이터 스트림으로 처리해 스레드 블로킹 방지
다양한 멀티 스레드 프로그래밍 방법들이 등장했지만, 결국 스레드 기반 작업이라는 한계가 있었습니다.
- 위 예시처럼, 스레드가 아무 작업도 하지 못하고 블로킹되는 상황이 있다면 컴퓨터의 자원이 낭비됩니다.
- 콜백이나 체이닝 함수를 통해 스레드 블로킹을 피해볼 수도 있지만, 작업 간 종속성이 복잡해질수록 스레드 블로킹은 피하기 어렵습니다.
코루틴은 작업 단위 코루틴을 통해 스레드 블로킹 문제를 해결합니다.
Coroutines
특징 1: 경량 스레드로써 동작
코루틴은 작업이 일시 중단되면, 더 이상 스레드 사용이 필요하지 않아 스레드의 사용 권한을 양보하며, 양보된 스레드는 다른 작업 실행하는데 사용할 수 있습니다. 일시 중단된 코루틴은 재개 시점에 다시 스레드를 할당받아 실행됩니다.
코루틴이 경량 스레드라고 불리는 이유
- 코루틴이 스레드를 사용하던 중, 스레드가 필요하지 않게 되면 다른 코루틴이 쓸 수 있도록 스레드를 양보하여 스레드 블로킹이 발생하지 않음
- JVM에서 스레드 당 최소 64KB의 스택 영역을 가져야하는 반면, 단순 코루틴은 수십 byte의 공간만 차지함
특징 2: 구조화된 동시성 제공
CoroutineScope
를 통해 동시성(concurrency) 코드를 구조적으로(structured) 작성할 수 있습니다.CoroutineScope
는 자식 scope가 모두 끝나기 전까지는 부모 scope가 종료되지 않는 특징을 갖습니다.- 위 예시에서
coroutineScope1Function()
이 끝난 후에야coroutineScope2Function()
이 실행됨
- 위 예시에서
- 구조화된 동시성 덕분에 코루틴에는 아래와 같은 이점이 있습니다.
- 자원 누수 방지
- 오류 처리
- 작업 취소(중단) 처리
- 가독성 향상 & 유지보수 용이
Coroutine 실행해보기
코틀린은 언어 수준에서 코루틴을 지원하지만, 저수준 API만 제공하여 사용하는데는 한계가 있습니다. 따라서, Jetbrains에서 제공하는 아래 라이브러리를 사용하는 것이 일반적입니다.
org.jetbrains.kotlinx:kotlinx-coroutines-core:{VERSION}
runBlocking과 launch
runBlocking
runBlocking
함수는 해당 함수를 호출한 스레드를 사용해 실행되는 코루틴을 만듭니다.runBlocking
은 다른 코루틴 빌더와 다르게 코루틴을 시작한 스레드를 블로킹합니다.- 위 예시의 35번 라인이 33번 라인 출력 이후에 출력되는 것을 확인할 수 있음
launch
CoroutineScope
객체의 확장 함수로 정의되어 있습니다.runBlocking
함수의 람다식에서 수신 객체인CoroutineScope
를 접근할 수 있기 때문에,runBlocking
내부에서launch
함수를 호출해 코루틴을 추가로 생성할 수 있습니다.
suspend
중단 함수(suspend function)는 말 그대로 코루틴을 중단할 수 있는 함수입니다.
- 이는 중단 함수가 반드시 코루틴(또는 다른 중단 함수)에 의해 호출되어야 함을 의미합니다. (위 예시 참고)
- 위 예제에서 볼 수 있듯이 중단 함수에 의해 코루틴이 중단(22번 라인)되면, 해당 스레드를 다른 코루틴(launch 2 코루틴)에 양보합니다.
- 코루틴 내에서 일반 함수에 의해 스레드가 블로킹되면, 해당 블로킹이 해제된 순간이 되어서야 다른 코루틴에게 스레드를 양보합니다.
참고 자료
- 도서 - 코틀린 코루틴의 정석(조세영 저)
- geeksforgeeks - Difference between Program and Process
- geeksforgeeks - How JVM Works - JVM Architecture?
- geeksforgeeks - Difference between Process and Thread
- stackoverflow - Why is creating a Thread said to be expensive?
- tistory - JavaThread에 대해 깊게 이해해보자
- stackoverflow - Why kotlin coroutines are considered light weight?
- Medium - Structured Concurrency 이해하기