프로세스는 실행 중인 애플리케이션의 인스턴스다.
애플리케이션이 시작될 때, 애플리케이션의 프로세스가 시작된다.
프로세스는 상태를 갖고 있다.
리소스를 여는 핸들, 프로세스 ID, 데이터, 네트워크 연결 등은 프로세스 상태의 일부이고, 해당 프로세스 내부의 스레드가 액세스를 할 수 있다.
애플리케이션은 여러 프로세스로 구성될 수 있다.
실행 스레드는 프로세스가 실행할 일련의 명령을 포함한다.
따라서 프로세스는 최소한 하나의 스레드를 포함하며 이 스레드는 애플리케이션의 진입점을 실행하기 위해 생성된다.
보통 진입점은 애플리케이션의 main() 함수이며 메인 스레드라 하는데 프로세스의 생명주기와 밀접하게 연관된다.
스레드가 끝나면 프로세스의 다른 스레드와 상관없이 프로세스가 종료된다.
fun main(args: Array<String>) {
doWork()
기본적인 애플리케이션이 실행되면 main() 함수의 명령 집합이 포함된 메인 스레드가 생성된다.
doWork()은 메인 스레드에서 실행되므로 doWork()이 종료되면 애플리케이션의 실행이 종료된다.
각 스레드는 스레드가 속한 프로세스에 포함된 리소스를 액세스하고 수정할 수 있지만 '스레드 로컬 스토리지'라는 자체 저장소도 갖고 있다.
스레드 안에서 명령은 한 번에 하나씩 실행돼 스레드가 블록되면 블록이 끝날 때까지 같은 스레드에서 다른 명령을 실행할 수 없다.
그러나 많은 스레드가 같은 프로세스에서 생성될 수 있으며 서로 통신할 수 있다.
따라서 애플리케이션이 사용자 경험에 부정적인 영향을 미칠 수 있는 스레드는 블로킹하지 않아야 한다.
블로킹할 때는 블로킹 작업을 별도의 전용 스레드에 할당해야 한다.
GUI 애플리케이션에는 UI 스레드가 있다.
UI 스레드는 사용자 인터페이스를 업데이트하고 사용자와 애플리케이션 간의 상호작용을 리스닝 하는 일을 한다.
스레드를 블록하면 애플리케이션이 UI를 업데이트하거나 사용자로부터 상호작용을 수신하지 못하도록 방해한다.
GUI 애플리케이션은 애플리케이션의 응답성을 항상 유지하기 위해서 UI 스레드를 블록하지 않는다.
예를들면 안드로이드에선 UI 스레드에서 네트워킹 작업을 하면 애플리케이션이 중단된다.
네트워킹 작업이 스레드를 블로킹한다는 점을 감안해서 개발자가 이를 수행하지 못하도록 하기 위함이다.
코틀린 동시성을 구현한 방식은 직접 스레드를 시작하거나 중지할 필요가 없다.
한두 줄의 코드로 코틀린이 특정 스레드나 스레드 풀을 생성해서 코루틴을 실행하도록 지시하기만 하면 된다.
스레드와 관련된 나머지 처리는 프레임워크에 의해 수행된다.
코틀린 문서에서는 코루틴을 경량 스레드라고 한다.
대부분의 스레드와 마찬가지로 코루틴이 프로세서가 실행할 명령어 집합의 실행을 정의하기 때문이다.
또한 코루틴은 스레드와 비슷한 라이프 사이클을 가지고 있다.
코루틴은 스레드 안에서 실행된다. 스레드 하나에 많은 코루틴이 있을 수 있지만 주어진 시간에 하나의 스레드에서 하나의 명령만이 실행될 수 있다.
즉 같은 스레드에 10개의 코루틴이 있다면 해당 시점에는 하나의 코루틴만 실행된다.
스레드와 코루틴의 가장 큰 차이점은 코루틴이 빠르고 적은 비용으로 생성할 수 있다는 것이다.
수천 개의 코루틴도 쉽게 생성할 수 있으며, 수천 개의 스레드를 생성하는 것보다 빠르고 자원도 훨씬 적게 사용한다.
suspend fun createCoroutines(amount: Int) {
val jobs = ArrayList<Job>()
for (i in 1..amount) {
jobs += launch {
delay(1000)
}
}
jobs.forEach {
it.join()
}
}
함수는 파라미터 amount에 지정된 수만큼 코루틴을 생성해 각 코루틴을 1초 간 지연시킨 후 모든 코루틴이 종료될 때까지 기다렸다가 반환한다.
예를 들어 이 함수는 수량을 10,000으로 설정해서 호출될 수 있다.
fun main(args: Array<String>) = runBlocking {
val time = measureTimeMillis {
createCoroutines(10_000)
}
println("Took $time ms")
}
테스트 환경에서 amount를 10,000으로 실행할 때 약 1,160ms가 걸리는데 반해 100,000으로 실행하는 데 1,649ms가 소요됐다.
코틀린은 고정된 크기의 스레드 풀을 사용하고 코루틴을 스레드들에 배포하기 때문에 실행 시간이 매우 적게 증가한다.
따라서 수천 개의 코루틴을 추가하는 것은 거의 영향이 없다.
코루틴이 일시 중단되는 동안 실행 중인 스레드는 다른 코루틴을 실행하는 데 사용되며 코루틴은 시작 또는 재개될 준비 상태가 된다.
Thread 클래스의 activeCount() 메소드를 활용하면 활성화된 스레드 수를 알 수 있다.
예를 들어 main() 함수를 업데이트해 다음 작업을 수행한다.
fun main(args: Array<String>) = runBlocking {
println("${Thread.activeCount()} threads active at the start")
val time = measureTimeMillis {
createCoroutines(10_000)
}
println("${Thread.activeCount()} threads active at the end")
println("Took $time ms")
이전과 같은 테스트 환경에서 10,000개의 코루틴을 생성하기 위해서 4개의 스레드만 생성하면 된다.
만약 createCoroutines의 값을 1로 낮추면 2개의 스레드가 생성된다.
코루틴이 특정 스레드 안에서 실행되더라도 스레드와 묶이지 않는다는 점을 이해해야한다.
코루틴의 일부를 특정 스레드에서 실행하고, 실행을 중지한 다음 나중에 다른 스레드에서 계속 실행하는 것이 가능하다.
이전 예제에서도 일어났던 것으로 코틀린이 실행 가능한 스레드로 코루틴을 이동시키기 때문이다. 가령 createCoroutines()에 amount를 3으로 하고, launch() 블록을 다음과 같이 변경해서 현재 실행 중인 스레드를 출력시키면 실제 내부에서 일어나는 일을 볼 수 있다.
suspend fun createCoroutines(amount: Int) {
val jobs = ArrayList<Job>()
for (i in 1..amount) {
jobs += launch {
println("started $i in ${Thread.currentThread().name}")
delay(1000)
println("finished $i in ${Thread.currentThread().name}")
}
}
jobs.forEach {
it.join()
}
}
위 코드를 적용하면 다른 스레드에서 다시 시작하는 경우가 많음을 알게 될 것이다.
스레드는 한 번에 하나의 코루틴만 실행할 수 있기 때문에 프레임워크가 필요에 따라 코루틴을 스레드들 사이에 옮기는 역할을 한다.
코틀린은 개발자가 코루틴을 실행할 스레드를 저장하거나 코루틴을 해당 스레드로 제한할지 여부를 지정할 수 있을 만큼 충분히 유연하다.
지금까지 애플리케이션이 하나 이상의 프로세스로 구성돼 있고 각 프로세스가 하나 이상의 스레드를 갖고 있음을 배웠다.
스레드를 블록한다는 것은 그 스레드에서 코드의 실행을 중지한다는 의미인데, 사용자와 상호작용하는 스레드는 블록되지 않아야 한다.
코루틴이 기본적으로 스레드 안에 존재하지만 스레드에 얽매이지 않은 가벼운 스레드라는 것을 알 수 있었다.
동시성은 애플리케이션이 동시에 한 개 이상의 스레드에서 실행될 때 발생한다.
동시성이 발생하려면 두 개 이상의 스레드가 생성돼야 하며, 애플리케이션이 제대로 작동하려면 이런 스레드 간의 통신과 동기화가 필요하다.
코틀린 동시성 프로그래밍 책을 참고하여 정리한 글입니다.