[코틀린 동시성] CH1 Hello, Concurrent World!

1
post-thumbnail

[코틀린 동시성] CH1 Hello, Concurrent World!

이 포스팅은 <코틀린 동시성 프로그래밍>, 미구엘 엔젤 카스티블랑코 토레스, 에이콘출판사(2020)을 읽고 개인 학습용으로 정리한 글입니다.

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

  • 어플리케이션을 시작할 때 운영체제는
    -> 프로세스를 생성하고 여기에 스레드를 연결
    -> 메인 스레드(main thread)로 알려진 스레드를 시작

프로세스

  • 프로세스: 실행 중인 애플리케이션의 인스턴스

  • 애플리케이션이 시작될 때마다 프로세스가 시작된다

  • 프로세스는 상태를 갖고 있다

    • ex. 리소스를 여는 핸들, 프로세스 ID, 데이터, 네트워크 연결 등
    • 프로세스 상태는 해당 프로세스 내부의 스레드가 엑세스 가능
  • 애플리케이션은 여러 프로세스로 구성될 수 있다

스레드

  • 실행 스레드는 프로세스가 실행할 일련의 명령을 포함

  • 프로세스는 최소한 하나의 스레드 포함
    -> 메인 스레드
    -> 이 스레드는 애플리케이션의 진입점(entry)을 실행하기 위해 생성됨
    -> 보통 진입점은 애플리케이션의 main() 함수

  • 메인 스레드는 프로세스의 라이프 사이클과 밀접하게 연관
    -> 메인 스레드가 끝나면 프로세스의 다른 스레드와 상관없이 프로세스 종료

fun main(args: Array<String>){
	doWork()
}
  • 기본적인 애플리케이션이 실행되면 main() 함수의 명령 집합이 포함된 메인스레드가 생성됨
    -> doWork()는 메인스레드에서 실행됨
    -> doWork()이 종료되면 메인 스레드 종료 -> 애플리케이션 실행 종료

  • 각 스레드는
    • 스레드가 속한 프로세스에 포함된 리소스를 액세스, 수정 가능
    • 스레드 로컬 스토리지(thread local storage)라는 자체 저장소 가짐

  • 스레드 안에서 명령은 한 번에 하나씩 실행

  • 스레드가 block되면 블록이 끝날 때까지 같은 스레드 내에서 다른 명령 실행 X
    -> 어플리케이션 UX에 부정적 영향을 미칠 수 있는 스레드 블로킹 X
    -> 블로킹 작업을 별도의 전용 스레드에 할당해야

  • 그래픽 사용자 인터페이스(GUI) 어플리케이션은 UI 스레드를 가짐
    -> UI스레드: 사용자 인터페이스 업데이트, 사용자와 어플리케이션 간의 상호작용 리스닝
    -> 어플리케이션의 응답성을 항상 유지하기 위해 UI 스레드 블록 X

  • 안드로이드 3.0 이상에서는 UI 스레드에서 네트워킹 작업을 하면 어플리케이션 중단
    -> 네트워킹 작업은 스레드를 중단시킬 수 있기 때문

  • 코틀린에서의 동시성 구현
    -> 직접 스레드를 시작하거나 중지할 필요 X
    -> 코틀린이 특정 스레드나 스레드 풀을 생성해서 코루틴을 실행하도록 지시
    -> 스레드와 관련된 나머지 처리는 프레임워크에 의해 수행

코루틴

  • 코루틴(coroutine): 경량 스레드
    -> 프로세서가 실행할 명령어 집합의 실행 정의
    -> 스레드와 비슷한 라이프사이클

  • 코루틴은 스레드 안에서 실행된다
    -> 스레드 하나에 많은 코루틴이 있을 수 있음
    -> 주어진 시간에 하나의 스레드에서 하나의 코루틴만 실행됨

  • 스레드와 코루틴의 차이점:
    코루틴은 빠르고 적은 비용으로 생성할 수 있다

suspend fun createCoroutines (amount: Int){
    val jobs = ArrayList<Job>()
    for(i in 1..amount){
        jobs += launch{
            delay(1000)
        }
    }
    jobs.forEach{
        it.join()
    }
}
  • 파라미터 amount에 지정된 수만큼 코루틴 생성
    -> 각 코루틴 1초간 지연시킴
    -> 모든 코루틴이 종료될 때까지 기다렸다가 반환
fun main(args: Array<String>) = runBlocking{
    val time = measureMillis{
        createCoroutines(10000)
    }
    println("Took $time ms")
}
  • measureMillis(): 코드 블록을 갖는 인라인 함수
    -> 실행 시간을 밀리초(ms)로 반환
    -> 코드의 실행 시간을 대략적으로 예측할 때 유용

  • 테스트 환경에서
    amount = 10,000 -> 1160ms 소요
    amount = 100,000 -> 1649ms 소요

  • 코틀린은 고정된 크기의 스레드 풀 사용 & 코루틴들을 스레드에 배포
    -> 실행 시간 매우 적게 증가

  • 코루틴이 일시 중단되는 동안(delay() 호출) 실행 중인 스레드는 다른 코루틴은 실행하는 데 사용됨

  • 코루틴이 특정 스레드 안에서 실행되더라도 스레드와 묶이지 않는다
    -> 코루틴의 일부를 특정 스레드에서 실행 -> 실행 중지 -> 다른 스레드에서 계속 실행 가능
    -> 코틀린이 실행 가능한 스레드로 코루틴을 이동시키기 때문

  • 프레임워크가 필요에 따라 코루틴을 스레드 사이에 옮기는 역할
    -> 개발자가 코루틴 실행 할 스레드 결정 가능
    -> 개발자가 코루틴을 특정 스레드로 제한할지 여부 결정 가능

동시성에 대해

  • 동시성: 어플리케이션이 동시에 한 개 이상의 스레드에서 실행될 때 발생
    -> 두 개 이상의 스레드 생성되어야 동시성 발생
    -> 이런 스레드 간의 통신과 동기화 필요

  • 올바른 동시성 코드:
    결정론적인 결과를 갖지만 실행 순서에서는 약간의 가변성 허용
    -> 코드의 서로 다른 부분이 어느정도 독립성이 있어야

  • 비동시성 코드(순차 코드)
fun getProfile(id: Int) : Profile{
    val basicUserInfo = getUserInfo(id)
    val contactInfo = getContactInfo(id)
    
    return createProfile(basicUserInfo, contactInfo)                     
}

  • getContactInfo()와 getUserInfo()의 실행시간 겹치지 않는다
    -> getContactInfo()의 실행 항상 getUserInfo()가 종료된 후 실행
  • 순차 코드의 장점: 정확한 실행 순서를 알 수 있음

  • 순차 코드의 문제점:

  1. 동시성 코드에 비해 성능이 저하될 수 있음
  2. 코드가 실행되는 하드웨어를 제대로 활용하지 못할 수 있음

  • 동시성 코드
suspend fun getProfile(id: Int) : Profile{
    val basicUserInfo = asyncGetUserInfo(id)
    val contactInfo = asyncGetContactInfo(id)
    
    return createProfile(basicUserInfo.await(), contactInfo.await())                     
}
  • getProflie()은 일시 중단 함수(suspend function)

  • asyncGetUserInfo(), asyncGetContactInfo() 비동기(asynchronous)로 구현
    -> 서로 다른 스레드에서 동시에 실행된다고 가정
    -> 동시성 발생

  • createProfile()를 호출할 때 두 개의 await() 호출
    -> asyncGetUserInfo(), asyncGetContactInfo()가 모두 완료될 때까지 createProfile()의 실행 일시 중단
    -> 어떤 동시성 호출이 먼저 종료되는지에 관계없이 결과 결정론적

  • asyncGetUserInfo(), asyncGetContactInfo() 중복 실행된다

  • 동시성 코드가 까다로운 이유:
    코드의 준독립적인(semi-independent)부분이 완성되는 순서에 관계없이 결과가 결정론적임을 보장해야

동시성은 병렬성이 아니다

  • 병렬적 실행을 위한 타임라인은 동시성 타인라인과 같아 보일 것

  • 같은 프로세스 안에서 서로 다른 명령 집합의 타임라인이 겹칠 때 동시성 발생
    -> 정확히 같은 시점에 실행되는지 여부와 상관 X

  • 병렬 실행(paralled execution)은 두 스레드가 정확히 같은 시점에 실행될 때만 발생

  • getProfile()의 코드를 코어가 하나만 있는 기계에서 실행하는 경우를 가정
    -> asyncGetUserInfo(), asyncGetContactInfo() 스케쥴 겹침
    -> 단일 코어에서 두 스레드 동시에 실행될 수 없음
    -> 교차 배치(interleave)됨

  • getProfile()의 코드를 두개의 코어가 있는 기계에서 실행하는 경우를 가정
    -> 코어 하나는 asyncGetUserInfo() 실행
    -> 코어 하나는 asyncGetContactInfo() 실행

  • 동시성은 두 개 이상의 알고리즘의 실행시간이 겹칠 때 발생
    -> 중첩 가능하려면 두 개 이상의 실행 스레드 필요
    -> 단일 코어에서 실행되면 서로 다른 스레드의 instructions 교차 배치 -> 병렬이 아니라 동시에(concurrently)

  • 병렬은 두 개의 알고리즘이 정확히 같은 시점에 실행될 때 발생
    -> 두 개 이상의 코어두 개이상의 스레드 필요
    -> 각 코어가 동시에 스레드의 instruction 실행

  • 병렬은 동시성을 의미, 동시성은 병렬 없이도 발생 가능

CPU 바운드와 I/O 바운드

  • 병목 현상: 다양한 유형의 성능저하가 발생하는 지점

  • CPU 바운드: CPU만 완료되면 되는 작업을 중심으로 구현되는 알고리즘
    -> 알고리즘의 성능 CPU의 성능에 좌우

  • I/O 바운드: 입출력 장치에 의존하는 알고리즘
    -> 실행 시간은 입출력 장치의 속도에 따라 달라짐
    -> 네트워킹이나 컴퓨터 주변기기로부터 입력을 받는 작업들도 I/O 작업

CPU 바운드 알고리즘에서의 동시성과 병렬성

  • 다중 코어에서 병렬성을 활용하면 성능 향상

  • 단일 코어에서 동시성을 구현하면 성능 저하
    -> 하나의 코어에 스레드 여러개 교차 배치됨
    -> 컨텍스트 스위칭(context switching) 오버헤드 발생

I/O 바운드 알고리즘에서의 동시성과 병렬성

  • I/O 바운드인 동시성 알고리즘은 병렬/단일 코어에 관계없이 유사하게 수행됨
    -> 순차적인 알고리즘보다 항상 더 좋은 성능

동시성이 어려운 이유

  • 레이스 컨디션(Race condition): 동시성 코드 일부가 제대로 동작하기 위해 일정한 순서로 완료되어야 할때 발생
    -> 정보에 접근하기 전 정보를 얻을 때까지 명시적으로 기다려야

  • 원자성 위반(atomic operations)
    -> 객체의 상태가 동시에 수정될 수 있을 때 원자성 필요
    -> 객체의 상태의 수정이 겹치지 않도록 보장해야

  • 교착 상태(deadlock): 순환적 의존성으로 인해 전체 어플리케이션의 실행이 중단되는 상황
    -> 일반적으로 복잡한 잠금 관계(network of locks)에 의해 발생
    -> 일반적으로 레이스 컨디션과 자주 같이 발생

  • 라이브 락(live lock): 애플리케이션의 상태 지속적으로 변하지만 어플리케이션이 정상 실행으로 돌아오지 못하게 하는 방향으로 상태가 변하는 경우

코틀린에서의 동시성

넌 블로킹

  • 코틀린은 중단 가능한 연산(suspendable computations) 기능 제공
    -> 스레드의 실행을 블로킹하지 않으면서 실행을 잠시 중단
    -> 그동안 해당 스레드를 다른 연산 작업에 사용

명시적인 선언

  • 연산이 동시에 실행돼야 하는 시점 명시적으로 만드는 것 중요
fun main(args: Array<String>) = runBlocking{
    val time = measureTimeMillis{
        val name = getName()
        val lastName = getLastName()
        println("Hello, $name $lastName")
    }
    println("Execution took $time ms")
}

suspend fun getName(): String{
    delay(1000)
    return "Sunjoo"
}

suspend fun getLastName(): String{
    delay(1000)
    return "Lee"
}
Hello, Sunjoo Lee
Execution took 2010 ms
  • 일시 중단 가능한 연산은 기본적으로 순차적으로 실행
    -> 실행 스레드를 블록하지 않는 비동시성 코드 작성 편리

fun main(args: Array<String>) = runBlocking{
    val time = measureTimeMillis{
        val name = async{ getName()}
        val lastName = async{ getLastName()}
        println("Hello, ${name.await()} ${lastName.await()}")
    }
    println("Execution took $time ms")
}
Hello, Sunjoo Lee
Execution took 1022 ms
  • async{...}를 호출해 두 함수 동시에 실행
  • await() 호출해 두 연산에 모두 결과가 나타낼 때까지 main() 일시중단 요청

가독성

  • 코틀린은 관용구적(idiomatic) 동시성 코드 허용
    -> 관례상으로 동시에 실행될 함수는 async로 시작/Async로 끝나도록 이름 지음
suspend fun getProfile(id: Int) : Profile{
    val basicUserInfo = asyncGetUserInfo(id)
    val contactInfo = asyncGetContactInfo(id)
    
    return createProfile(basicUserInfo.await(), contactInfo.await())                     
}
  • 백그라운드에서 실행될 두 메소드 호출 & 정보를 처리하기 전 완료를 기다림

  • suspend 메소드
    -> async{} 또는 launch{} 블록 안에서 호출

기본형 활용

  • 동시성 코드를 쉽게 구현할 수 있는 고급함수, 기본형 제공

  • 스레드 생성:
    스레드 이름을 파라미터로 newSingleThreadContext() 호출

  • 스레드 풀 생성:
    크기와 이름을 파라미터로 newFixedThreadPoolContext() 호출

  • CommandPool: CPU 바운드 작업에 최적인 스레드 풀
    -> 최대 크기: 시스템 코어의 수 - 1

  • 코루틴을 다른 스레드로 이동시키는 역할은 런타임이 담당

  • 코루틴의 통신과 동기화를 위해 필요한 많은 기본형과 기술 제공
    -> ex. 채널, 뮤텍스, 스레드 한정, ...

유연성

  • 채널(channel): 코루틴 간에 데이터를 안전하게 보내고 받는 데 사용할 수 있는 파이프

  • 작업자 풀(Workers pools): 많은 스레드에서 연산 집합의 처리를 나눌 수 있는 코루틴의 풀

  • 액터(Actors): 채널과 코루틴을 사용하는 상태를 감싸는 래퍼
    -> 여러 스레드에서 상태를 안전하게 수정하는 매커니즘 제공

  • 뮤텍스(Mutex): 한 번에 하나의 스레드만 실행할 수 있도록 하는 동기화 메커니즘
    -> 크리티컬 존(Critical Zone) 영역 정의

  • 스레드 한정(Thread confinement): 코루틴의 실행을 제한해서 지정된 스레드에서만 실행하도록 하는 기능

  • 생상자(반복자 및 시퀸스): 필요에 따라 정보 생성 가능, 새로운 정보가 필요하지 않을 때 일시 중단될 수 있는 데이터 소스

코틀린 동시성 관련 개념과 용어

일시 중단 연산

  • 일시 중단 연산(suspendable computations): 해당 스레드를 차단하지 않고 실행을 일시 중지할 수 있는 연산

일시 중단 함수

  • 일시 중단 함수: 함수 형식의 일시 중단 연산

  • suspend 제어자 사용

suspend fun greetAfter(name: String, delayMillis: Long){
    delay(delayMillis)
    println("Hello, $name")
}
  • delay(): 자체가 일시 중단 함수
    -> 주어진 시간 실행 일시 중단
    -> delay()가 완료되면 greetAfter() 실행 정상적으로 다시 시작

  • greetAfter()가 일시중단된 동안 실행 스레드 다른 연상을 수행하는 데 사용됨

람다 일시 중단

  • 일시 중단 람다: 익명의 로컬 함수
    -> 일시 중단 함수(ex. delay())를 호출함으로써 자신의 실행 중단 가능

코루틴 디스패치

  • 코루틴 디스패처(Coroutine Dispatcher): 코루틴을 시작하거나 재개할 스레드를 결정하기 위해 사용

  • 모든 코루틴 디스패처는 CoroutineDispatcher 인터페이스 구현해야

  • Common Pool: 공유된 백그라운드 풀에서 코루틴을 실행하고 다시 시작
    -> 기본 크기: CPU 바운드 작업에서 사용하기에 적합

  • Unconfined: 현재 스레드에서 코루틴을 시작, 어떤 스레드에서도 코루틴이 다시 재개될 수 있음

  • newSingleThreadContext(): 단일 스레드로 디스패치 생성
    -> 여기서 실행되는 코루틴 항상 같은 스레드에서 실행되고 재개

  • newFixedThreadPoolContext(): 지정된 크기의 스레드 풀이 있는 디스패처 생성
    -> 디스패처에서 실행된 코루틴을 시작하고 재개할 스레드 런타임이 결정

코루틴 빌더

  • 코루틴 빌더: 일시 중단 람다를 받아 그것을 실행시키는 코루틴을 생성하는 함수

  • async: 결과가 예상되는 코루틴을 시작하는 데 사용
    -> 코루틴 내부에서 일어나는 모든 예외를 캡처해서 결과에 넣음
    -> 결과 또는 예외를 포함하는 Deferred 반환

  • launch: 결과를 반환하지 않는 코루틴을 시작하는데 사용
    -> 자체 혹은 자식 코루틴의 실행을 취소하기 위해 사용할 수 있는 Job 반환

  • runBlocking: 블로킹 코드를 일시 중지 가능한 코드로 연결하기 위해 작성
    -> 보통 main() 메소드와 유닛 테스트에서 사용됨
    -> ⚡코루틴의 실행이 끝날 때까지 현재 스레드 차단

val result = GlobalScope.async{
    isPalindrome(word = "Sample")
}
result.await()
  • 디폴트 디스패처에서 async() 실행됨
val result = GlobalScope.async(Dispatchers.Unconfined){
    isPalindrome(word = "Sample")
}
result.await()
  • 코루틴 디스패처로 Unconfined 사용
profile
Be able to be vulnerable, in search of truth

0개의 댓글