코틀린 스터디

코루틴 기반 동시성 프로그래밍

막이86 2023. 11. 24. 17:57
728x90

코틀린 동시성 프로그래밍을 요약한 내용입니다.

프로세스, 스레드, 코루틴

프로세스

  • 프로세스는 실행 중인 애플리케이션의 인스턴스
  • 프로세스는 상태를 갖고 있다
    • 핸들, 프로세스 ID, 데이터, 네트워크 등의 상태
    • 프로세스 내부의 스레드가 액세스 할 수 있다
  • 애플리케이션은 여러 프로세스로 구성될 수 있다
    • 브러우저 같은 경우 여러 프로세스로 구성

스레드

  • 실행 스레드는 프로세스가 실행할 일련의 명령을 포함
  • 프로세스는 최소한 하나의 스레드를 포함
    • 애플리케이션의 진입정을 실행하기 위해 생성
  • 애플리케이션이 실행되면 main() 함수의 명령 집합이 포함된 메인 스레드가 생성
    • doWork는 메인 스레드에서 실행되므로 doWork() 종료되면 애플리케이션의 실행이 종료
    fun main(args: Array<String>) {
    	doWork()
    }
    
  • 스레드가 속한 프로세스에 포함된 리소스를 액세스 가능
  • 스레드는 로컬 스토리지라는 자체저장소도 갖고 있다
  • 스레드 안에서 명령은 한 번에 하나씩 실행돼 스레드가 블록되면 블록이 끝날 때까지 같은 스레드에서 다른 명령을 실행할 수 없다
  • GUI 애플리케이션에는 UI 스레드가 있다
  • UI 스레드는 사용자 인터페이스를 업데이트하고 사용자와 애플리케이션 간의 상호작용을 리스링 한다
  • 스레드를 블록하면 애플리케이션이 UI를 업데이트하거나 사용자로부터 상호작용을 수신하지 못하도록 방해

코루틴

  • 코루틴을 경량 스레드라고도 한다
  • 스레드와 마찬가지로 코루틴이 프로세스가 실행할 명령어 집합의 실행을 정의
  • 코루틴은 스레드와 비슷한 라이프 사이클을 갖고 있다
  • 코루틴은 스레드 안에서 실행
    • 스레드 하나에 많은 코루틴이 있을 수 있음
    • 주어진 시간에 하나의 스레드에서 하나의 명령만 실행 가능
  • 스레드와 코루틴의 가장 큰 차이점은 코루틴이 빠르고 적은 비용으로 생성할 있다
  • 함수는 amount에 지정된 수 만큼 코루틴을 생성해 각 코루틴을 1초가 지연시킨 후 모든 코루틴이 종료될 때까지 기다렸다가 반환
    • amount를 10,000 실행 시: 약 1,160ms
    • amount를 100,000 실행 시 : 약 1649ms
    • 코틀린은 고정된 크기의 스레드 풀을 사용하고 코루틴을 스레드들에게 배포하기 때문에 실행 시간이 매우 적게 증가
    • 수천 개의 코루틴을 추가하는 것은 거의 영향이 없다
    • 코루틴이 일시 중단되는 동안 실행 중인 스레드는 다른 코루틴을 실행하는 데 사용
    suspend fun createCoroutines(amount: Int) {
    	val jobs = ArrayList<job>()
    	for (i in 1..amount) {
    		jobs += launch {
    			delay(1000)
    		}
    	}
    	jobs.forEach {
    		it.join()
    	}
    }
    
    fun main(args: Array<String>) = runBlocking {
    	val time = measureTimeMillis {
    		createCoroutines(10_000)
    	}
    	println("Took $time ms")
    }
    

<aside> 💡 measureTimeMillis는 코드 블록을 갖는 인라인 함수이며 실행 시간을 밀리초로 반환

</aside>

  • Thread 클래스의 activeCount() 메소드를 활용하면 활성화된 스레드 수를 알수 있다
  • 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 th end") println("Too $time ms") }
  • 코루틴이 특정 스레드 안에서 실행되더라도 스레드와 묶이지 않는다는 점을 이해해야 한다
    • 코루틴의 일부를 특정 스레드에서 실행
    • 실행을 중지한 다음 나중에 다른 스레드에서 계속 실행 가능
    suspend fun createCoroutines(amount: Int) {
    	val jobs = ArrayList<job>()
    	for (i in 1..amount) {
    		jobs += launch {
    			println("Start $i in ${Thread.currentThread().name}")
    			delay(1000)
    			println("Finished $i in ${Thread.currentThread().name}")
    		}
    	}
    	jobs.forEach {
    		it.join()
    	}
    }
    
  • 스레드는 한 번에 하나의 코루틴만 실행할 수 있기 때문에 프레임워크가 필요에 따라 코루틴을 스레드들 사이에 옮기는 역활을 한다
  • 코틀린은 개발자가 코루틴을 실행할 스레드를 지정하거나 코루틴을 해당 스레드로 제한할지 여부를 지정할 수 있을 만큼 충분히 유연

Sync

동시성에 대해

  • 올바른 동시성 코드는 결정론적인 결괄르 갖지만 실행 순서에서는 약간의 가변성을 허용
  • 비동시성 코드
    • 사용자 정보가 반환되기 전까지 연락처 정보를 요청하지 않음
    fun getProfile(id: Int): Profile {
    	val basicUserInfo = getUserInfo(id)
    	val contactInfo = getContactInfo(id)
    
    	return createProfile(basicUserInfo, contactInfo)
    }
    
  • 순차 코드의 장점
    • 정확한 실행 순서를 쉽게 알 수 있어서 예측하지 못하는 일이 벌어지지 않음
  • 순차 코드의 단점
    • 동시성 코드에 비해 성능이 저하될 수 있음
    • 코드가 실행되는 하드웨어를 제대로 활용하지 못할 수 있음
  • 동시성 코드
    • 사용자 정보와 연락처 정보는 서로 다른 스레드에서 실행
    suspend fun getProfile(id: Int): Profile {
    	val basicUserInfo = asyncGetUserInfo(id)
    	val contactInfo = asyncGetContactInfo(id)
    
    	return createProfile(basicUserInfo.await(), contactInfo.await())
    }
    

동시성은 병렬성이 아니다

  • 동시성과 병렬성을 혼동하곤 한다
    • 두 개의 코드가 동시에 실행된다는 점에서 비슷해 보이긴 한다
  • 동시성 코드
    • asyncGetUserInfo와 asyncGetContactInfo는 중복 실행
    • createProfile은 중복 실행되지 않음
    suspend fun getProfile(id: Int): Profile {
    	val basicUserInfo = asyncGetUserInfo(id)
    	val contactInfo = asyncGetContactInfo(id)
    
    	return createProfile(basicUserInfo.await(), contactInfo.await())
    }
    
  • getProfile 코드를 코어가 하나만 있는 기계에서 실행할 경우
    • 단일 코어는 두 스레드를 동시에 실행 할 수 없음
    • asyncGetUserInfo와 asyncGetContactInfo 간에 교차 배치되 스케줄이 겹치지만 동시에 실행되지 않음
  • 병렬 실행은 두 스레드가 정확히 같은 시점에 실행될 때만 발생한다
  • 두 개의 코어가 있는 컴퓨터에서 getProfile이 실행되고 있는 경우
    • 코어 하나는 asyncGetUserInfo 실행
    • 다른 코어는 asyncGetContactInfo 실행
    • 스레드 타임라인이 겹칠 뿐만 아니라 정확히 같은 시점에 실행된다
  • 동시성은 두 개 이상의 알고리즘의 실행 시간이 겹쳐질 때 발생한다
    • 중첩이 발생하려면 두 개 이상의 실행 스레드가 필요
    • 스레드들이 단일 코어에서 실행되면 병렬이 아니라 동시에 실행
    • 단일 코어가 서로 다른 스레드의 인스트럭션을 교차 배치해서 효율적으로 실행
  • 병렬은 두 개의 알고리즘이 정확히 같은 시점에 실행될 때 발생
    • 2개 이상의 코어와 2개 이상의 스레드가 있어야 각 코어가 동시에 스레드의 인스트럭션을 실행
    • 병렬은 동시성을 의미하지만 동시성은 병렬성이 없어도 발생할 수 있다는 점에 유의

코틀린에서의 동시성

넌 블로킹

  • 스레드는 무겁고 생성하는데 비용이 많이 들며 제한된 수의 스레드만 생성할 수 있다.
  • 스레드가 블록킹되면 자원이 낭비되는 셈이여서 코틀린은 중단 가능한 연산이라는 기능을 제공
    • 스레드의 실행을 블록킹하지 않으면서 실행을 잠시 중단 하는 것

명시적인 선언

  • 동시성은 깊은 고민과 설계가 필요
  • 연산이 동시에 실행돼야 하는 시점을 명시적으로 만드는 것이 중요
  • 일시 중단 가능한 연산은 기본적으로 순차적으로 실행
    • 연산은 일시 중단될 때 스레드를 블로킹하지 않기 때문에 직접적인 단점은 아님
  • getName, getLastName을 순차적으로 실행
  • fun main(args: Array<String>) = runBlocking { val time = mesureTimeMillis { val name = getName() val lastName = getLastName() println("Hello, $name $lastName") } println("Excution took $time ms") } suspend fun getName(): String { delay(1000) return "Susan" } suspend fun getLastName(): String { delay(1000) return "Calvin" }
  • getName, getLastName을 동시에 수행
    • 두 함수를 동시에 실행하며 await을 호출해 두연산에 모두 결과가 나타날 때까지 main이 일시 중단되도록 요청
    fun main(args: Array<String>) = runBlocking {
    	val time = mesureTimeMillis {
    		val name = async { getName() }
    		val lastName = async { getLastName() }
    		println("Hello, ${name.await()} ${lastName.await()}")
    	}
    	println("Excution took $time ms")
    }
    
    suspend fun getName(): String {
    	delay(1000)
    	return "Susan"
    }
    
    suspend fun getLastName(): String {
    	delay(1000)
    	return "Calvin"
    }
    

가독성

  • 코틀린의 동시성 코드는 순차적 코드만큼 읽기 쉽다
  • 코틀린의 접근법은 관용구적인 동시성 코드를 허용
  • suspend 메소드는 백그라운드 스레드에서 실행될 두 메소드를 호출하고 정보를 처리하기 전에 완료를 기다린다
  • suspend fun getProfile(id: Int): Profile { val basicUserInfo = asyncGetUserInfo(id) val contactInfo = asyncGetContactInfo(id) return createProfile(basicUserInfo.await(), contactInfo.await()) }

기본형 활용

  • 스레드를 만들고 관리하는 것은 동시성 코드를 작성할 때 가장 어려운 부분 중 하나
  • 언제 스레드를 만들 것인가를 아는 것 못지 않게 얼마나 많은 스레드를 만드는지를 아는 것도 중요
  • 스레드를 통신/동기화 하는 것은 그 자체로 어려운일
  • 코틀린은 동시성 코드를 쉽게 구현할 수 있는 고급 함수와 기본형을 제공
    • 스레드는 스레드 이름을 파라미터로 하는 newSingleThreadContext()를 호출하면 생성
      • 생성되면 필요한 만큼 많은 코루틴을 수행하는데 사용
    • 스레드 풀은 크기와 이름을 파라미터로 하는 newFixedThreadPoolContet()를 호출하면 생성
    • CommonPool은 CPU 바운드 작업에 최적인 스레드 풀이다.
      • 최대 크기는 시스템 코어에서 1을 뺀 값
    • 코루틴을 다른 스레드로 이동시키는 역활은 런타임이 담당
    • 채널, 뮤텍스 및 스레드 한정과 같은 코루틴의 통신과 동기화를 위해 필요한 많은 기본형과 기술이 제공

일시 중단 연산

  • 일시 중단 연산은 해당 스레드를 차단하지 않고 실행을 일시 중지할 수 있는 연산
  • 스레드를 차단하는 것은 불편하기 때문에 자체 실행을 일시 중단하면 일시 중단 연산을 통해 스레드를 다시 시작해야 할 때까지 스레드를 다른 연산에서 사용 가능

일시 중단 함수

  • 일시 중단 함수는 suspend 제어자 때문에 쉽게 식별 가능
  • suspend fun greetAfter(name: String, delayMillis: Long) { delay(delayMillis) println("Hello, $name") }

람다 일시 중단

  • 일반적인 람다와 마찬가지로, 일시 중단 람다는 익명의 로컬 함수
  • 다른 일시 중단 함수를 호출함으로써 자신의 실행을 중단할 수 있다는 점에서 보통의 람다와 차이가 있다

코루틴 디스패처

  • 코루틴을 시작하거나 재개할 스레드를 결정하기 위해 코루틴 디스패처가 사용된다.
  • 모든 코루틴 디스패처는 CoroutinDispatcher 인터페이스를 구현해야 함
    • DefaultDispatcher
      • CommonPool과 같다
    • CommonPool
      • 공유된 백그라운드 스레드 풀에서 코루틴을 실행하고 다시 시작한다
      • 기본 크기는 CPU 바운드 작업에서 사용하기에 적합
    • Unconfied
      • 현재 스레드에서 코루틴을 시작하지만 어떤 스레드에서도 다시 재개될 수 있다.
      • 디스패처에서는 스레드 정책을 사용하지 않는다
  • 디스패처와 함께 필요에 따라 풀 또는 스레드의 정의하는 데 사용할 수 있는 빌더
    • newSingleThreadContext()
      • 단일 스레드로 디스패처를 생성
      • 실행되는 코루틴은 항상 같은 스레드에서 시작되고 재개된다.
    • newFixedThreadPoolContext()
      • 지정된 크기의 스레드 풀이 있는 디스패처를 만든다
      • 런타임은 디스패처에서 실행된 코루틴을 시작하고 재개할 스레드를 결정

코루틴 빌더

  • 일시 중단 람다를 받아 실행시키는 코루틴을 생성 하는 함수
  • 다양한 시나리오에 맞게 활용할 수 있는 코루틴 빌더를 제공
    • async()
      • 결과가 예상되는 코루틴을 시작하는 데 사용
      • 코루틴 내부에서 일어나는 모든 예외를 캡처해서 결과에 넣기 때문에 조심해서 사용
      • 결과 또는 예외를 포함하는 Deferrd<T>를 반환
    • launch()
      • 결과가 반환하지 않는 코루틴을 시작
      • 자체 혹은 자식 코루틴의 실행을 취소하기 위해 사용할 수 있는 Job을 반환
    • runBlocking()
      • 블로킹 코드를 일시 중지 가능한 코드로 연결하기 위해 작성
      • 보통 main 메소드와 유잇 테스트에서 사용 된다.
      • runBlocking은 코루틴의 시행이 끝날 때까지 현재 스레드를 차단
  • async() 예제
  • val result = GlobalScope.async { isPalindrome(word = "Sample") } result.await()
  • 디스패처를 수동으로 지정
  • val result = GlobalScope.async(Dispatchers.Unconfined) { isPalindrome(word = "Sample") } result.await()
728x90