목표
- Kotlin 코루틴이 등장한 배경과 기본 개념 소개
- Launch와 suspend 함수를 사용한 간단한 예제를 통해 코루틴의 기본 실습
JVM 프로세스와 스레드
프로그램이란?
- 프로그램은 디스크에 저장된 파일의 내용(ex. 소스코드 파일)처럼 보조 메모리에 저장된 수동적 개체(passive entity)이다.
- 실행되어야 할 몇 가지 명령들이 포함된 파일로, 실제로 수행하기 위해서는 메모리에 올려서 실행되어야 한다.
프로세스란?
- 프로세스(Job)은 CPU에 의해 실행될 수 있도록 컴퓨터 메모리에 로드된 프로그램 코드를 의미합니다.
- 프로세스는 컴퓨터에서 실행되는 프로그램의 인스턴스로도 볼 수 있습니다.
- 프로세스가 동작하기 위해서는 메모리 주소, CPU, I/O와 같은 리소스가 필요합니다.
- 프로세스는 수행되는 동안에만 존재합니다.
- 프로세스는 하위 프로세스라고 하는 다른 프로세스를 생성할 수 있고, 이 프로세스들 간에 격리되어 있어 메모리를 공유하지 않습니다.
스레드란?
- 프로세스는 여러 스레드를 가질 수 있고, 이러한 스레드들은 프로세스 내 포함되어 있어 격리되어 있지 않습니다.
결론
- 프로세스는 프로그램이 실행될 때에만 생성되는 프로그램의 하위 집합
- 프로그램은 특정 작업을 수행하도록 만들어진 명령어의 집합
JVM
JVM(Java Virtual Machine)은 컴퓨터 자원을 할당 받아 실행되는 프로세스로 자바 애플리케이션을 실행하는 런타임 엔진 역할을 합니다.
JVM 작동 방식
클래스 로더
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: 확장 클래스 로더 하위 개념으로, 애플리케이션 클래스 경로에서 클래스를 로드하는 역할을 함
- JVM은 클래스 로드하기 위해 위임 계층 원칙을 따릅니다.
- 시스템 클래스 로더는 로드 요청을 확장 클래스 로더에 위임하고, 확장 클래스 로더는 부트스트랩 클래스 로더에 위임합니다.
- 부트스트랩 경로에 클래스가 있으면 해당 클래스가 로드되고, 그게 아니라면 확장 클래스 로더로 전송된 다음 확인하는 식으로 진행됩니다.
JVM 메모리
- 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 리소스)를 잡아두고 있기 때문에, 비용이 발생합니다.
참고
- 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 이해하기
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Kotlin Coroutine] 코루틴 컨텍스트와 디스패처의 이해 (0) | 2024.06.19 |
---|---|
[Kotlin Coroutine] 코루틴 빌더와 비동기 패턴의 이해 (0) | 2024.06.16 |
[Kotlin] Collections의 Iterable과 Sequences 차이점 (0) | 2023.08.23 |
[Kotlin]enum class와 data class (0) | 2022.08.25 |
[Kotlin] 객체 (0) | 2022.08.25 |