[코틀린 동시성 프로그래밍] 1장 Hello, Concurrent World! -2

Sdoubleu·2023년 1월 15일
0

코틀린 동시성

목록 보기
2/10
post-thumbnail

CPU 바운드와 I/O 바운드

  • 병목 현상은 다양한 유형의 성능저하가 발생하는 지점을 나타냄
    -> 앱의 성능을 최적화 할 때 가장 중요한 사항

병목 현상이란?
: 두 구성 요소의 최대 성능의 차이로 인해 한 구성 요소가 다른 하드웨어의 잠재 성능을 제한하는 것을 말합니다.

Tip
동시성 코드가 항상 필요한 것도 아니고 이르르 통해 꼭 이득을 얻는 것도 아님
코드의 병목과 스레드 및 코루틴의 작동방식과 동시성 및 병렬성 간의 차이를 이해해 두면 동시성 소프트웨어를 언제 어떻게 구현해야 하는지를 판단 가능

CPU 바운드

알고리즘의 성능은 실행 중인 CPU의 성능에 좌우되며 CPU만 업그레이드해도 성능이 향상됨
-> 코드를 더 빠른 CPU에서 실행하면 코드의 변경 없이도 성능이 향상됨!!!

I/O 바운드

I/O 바운드는 입출력 장치에 의존하는 알고리즘
-> 실행 시간은 입출력 장치의 속도에 따라 달라짐

파일을 읽는 속도에 따라 성능이 달라지는 I/O 작업
ex) 파일을 하드드라이브에 저장하면 SSD에 저장하는 경우보다 앱 성능이 더 나빠짐

네트워킹이나 컴퓨터 주변기기로부터의 입력을 받는 작업들도 I/O 작업
I/O 바운드 알고리즘은 I/O작업을 기준으로 성능에 대한 병목 현상을 일으키는데,
최적화가 외부 시스템이나 장치에 의존한다는 것을 의미


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

  • CPU 바운드 알고리즘의 경우 다중 코어에서 병렬성을 활용하면 성능을 향상 시킬 수 있지만 단일 코어에서 동시성을 구현하면 성능이 저하되기도 함

단일 코어에서 실행

단일 코어에서 실행된다면 하나의 코어가 3개의 스레드 사이에서 교차 배치되며 매번 일정량의 단어를 필터링하고 다음 스레드로 전환됨

전환 프로세스를 컨텍스트 스위칭이라고 함

  • 컨텍스트 스위칭의 문제점
    -> 현재 스레드의 상태를 저장한 후 다음 스레드의 상태를 적재해야 하기 때문에 전체 프로세스에 오버헤드가 발생

병렬 코어에서 실행

병렬 실행의 경우 각 스레드가 하나의 전용 코어에서 실행된다고 가정하면 순차적 실행의 약 3분의1 시간이 소요될 것

  • CPU 바운드 알고리즘을 위해서는 현재 사용 중인 장치의 코어 수를 기준으로 적절한 스레드 수를 생성하도록 고려해야 함 !

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

  • 순차적인 알고리즘보다 동시성 구현에서 항상 더 나은 성능을 발휘할 것으로 예상

동시성이 어려운 이유

레이스 컨디션

동시성 코드를 작성할 때 가장 흔한 오류인 레이스 컨디션
-> 코드를 동시성으로 작성했지만 순차적으로 동작할 것이라고 예상할 때 발생

ex) DB에서 데이터를 가져오고 웹 서비스를 호출하는 기능을 동시에 수행하는 코드를 작성 중이라고 가정한다.
이 두 작업이 모두 끝나면 약간의 연산을 수행해야 한다.
많은 사람들이 가장 흔히 하는 실수는 DB가 더 빠를 것이라고 가정하고 웹 서비스 작업이 끝나마자 마자 DB 작업의 결과에 접근하려고 하는 것이다.
DB 작업이 웹 서비스 호출보다 오래 걸릴 때마다 앱이 중단되거나 일관되지 않은 상태에 빠진다.

  • 레이스 컨디션 해결책
    -> 정보에 접근하려고 하기 전에 정보를 얻을 때까지 명시적으로 기다려야 함

원자성 위반

원자성 작업이란 작업이 사용하는 데이터를 간섭 없이 접근할 수 있음을 말함
단일 스레드 앱에선 모든 코드가 순차적으로 실행되기 때문에 모든 작업이 모두 원자일 것
스레드가 하나만 실행되므로 간섭이 있을 수 없음

원자성은 객체의 상태가 동시에 수정될 수 있을 때 필요하며 그 상태의 수정이 겹치지 않도록 보장해야 함
-> 수정이 겹칠 수 있다는 것은 데이터 손실이 발생할 수 있다는 뜻
-> 코루틴이 다른 코루틴이 수정하고 있는 데이터를 바꿀 수 있다는 뜻

교착 상태

동시성 코드가 올바르게 동기화되려면 다른 스레드에서 작업이 완료되는 동안 실행을 일시 중단하거나 차단할 필요가 있음
-> 순환적 의존으로 인해 전체 앱의 실행이 중단되는 상황 발생

교착 상태를 발견하고 수정하기란 훨씬 어려움
레이스 컨디션과 자주 같이 발생

라이브 락

라이브 락은 앱이 올바르게 실행을 계속할 수 없을 때 발생하는 교착 상태와 유사
라이브 락이 진행될 때 앱의 상태는 지속적으로 변하지만
앱이 정상 실행으로 돌아오지 못하게 하는 방향으로 상태가 변한다는 점이 다름

교착 상태를 복구하도록 설계된 알고리즘에서 라이브 락이 발생하는 경우가 많음
교착 상태를 복구하려는 시도가 라이브 락을 만들어 낼 수 있음에 유의


코틀린에서의 동시성

넌 블로킹

스레드는 무겁고 생성하는 데 비용이 많이 들며 제한된 수의 스레드만 생성할 수 있음
스레드의 실행을 블로킹하지 않으면서 실행을 잠시 중단하는 것

ex) 스레드Y에서 작업이 끝나기를 기다리려면 스레드X를 블로킹하는 대신, 대기해야 하는 코드를 일시 중단하고 그 동안 스레드X를 다른 연산 작업에 사용하기도 한다.

코틀린은 채널(channels), 액터(actors), 상호 배제와 같은 훌륭한 기본형도 제공해 스레드를 블록하지 않고 동시성 코드를 효과적으로 통신하고 동기화하는 매커니즘을 제공

🛠️ 채널, 액터, 상호 배제에 대해 설명 필요

명시적인 선언

동시성은 깊은 고민과 설계가 필요해 연산이 동시에 실행돼야 하는 시점을 명시적으로 만드는 것이 중요
일시 중단 가능한 연산(Supendable 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 "Susan"
}
suspend fun getLastName(): String{
	delay(1000)
	return "Calvin"
}

getLastName()과 getName() 간에 서로 의존성이 없기 때문에
동시에 수행하는 편이 더 나음

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")
}

가독성

코틀린의 동시성 코드는 순차적 코드만큼 읽기 쉬움
코틀린의 접근법은 관용구적인 동시성 코드를 허용

suspend fun getProfile(id: Int) {
	val basicUserInfo = asyncGetUerInfo(id)
    val contactInfo = asyncGetContactInfo(id)

    createProfile(basicUserInfo.await(), contactInfo.await())
}

⚡Tip
관례상 기본적으로 동시에 실행될 함수는 async로 시작하거나 Async로 끝나도록 이름을 짓자

  • suspend 메소드는 백그라운드 스레드에서 실행될 두 메소드를 호출하고 정보를 처리하기 전에 완료를 기다림
    -> 순차 코드처럼 간단하게 읽고 디버깅하기 쉬운 코드가 됨


비동기 함수를 작성하는 대신 suspend 함수를 작성해 async {} 또는 launch{} 블록 안에서 호출하는 것이 좋다. suspend 함수를 갖게 되면 함수의 호출자에게 더 많은 유연성을 제공하기 때문이다. 가령 호출자가 언제 동시적으로 실해앟ㄹ 것인지를 결정할 수 있다. 그 밖에는 동시적 함수와 일시 중단 함수를 모두 작성하길 원할 때 유용하다.

기본형 활용

코틀린은 동시성 코드를 쉽게 구현할 수 있는 고급 함수와 기본형을 제공

  • 스레드는 스레드 이름을 파라미터로 하는 newSingleThreadContext()를 호출하면 생성된다. 일단 생성되면 필요한 만큼 코루틴을 수행하는 데 사용할 수 있음
  • 스레드 풀은 크기와 이름을 파라미터로 하는 newFixedThreadPoolContext()를 호출하면 쉽게 생성할 수 있음
  • CommonPool은 CPU 바운드 작업에 최적인 스레드 풀. 최대 크기는 시스템의 코아에서 1을 뺀 값
  • 코루틴을 다른 스레드로 이동시키는 역할은 런타임이 담당
  • 채널, 뮤텍스 및 스레드 한정과 같은 코루틴의 통신과 동기화를 위해 필요한 만큼 기본형과 기술이 제공

유연성

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

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

  • 액터: 채널과 코루틴을 사용하는 상태를 감싼 래퍼로 여러 스레드에서 상태를 안전하게 수정하는 메커니즘을 제공

  • 뮤텍스: 크리티컬 존 영역을 정의해 한 번에 하나의 스레드만 실행할 수 있도록 하는 동기화 메커니즘. 크리티컬 존에 엑세스하려는 코루틴은 이전 코루틴이 크리티컬 존을 빠져나올 때까지 일시 정지

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

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


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

일시 중단 연산

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

⚡Tip
일시 중단 연산은 다른 일시 중단 함수 또는 코루틴에서만 호출되는 특징

일시 중단 함수

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

greetAfter()의 실행은 delay()가 호출될 때 일시 중단
delay()는 자체 일시 중단 함수이며, 주어진 시간동안 실행을 일시 중단
greetAfter()가 일시 중단된 동안 실행 스레드가 다른 연산을 수행하는데 사용될 수 있음

람다 일시 중단

  • 일반적인 람다와 마찬가지로, 일시 중단 람다는 익명의 로컬 함수

  • 일시 중단 람다는 다른 일시 중단 함수를 호출함으로써 자신의 실행을 중단할 수 있다는 점에서 보통의 람다와 차이가 있음

코루틴 디스패처

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

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

  • DefaultDispatcher: 현재는 CommonPool과 같음. 앞으로 바뀔 수 있음

  • CommonPool: 공유된 백그라운드 스레드 풀에서 코루틴을 실행하고 다시 시작함. 기본 크기는 CPU 바운드 작업에서 사용하기에 적합

  • Unconfined: 현재 스레드에서 코루틴을 시작하지만 어떤 스레드에서도 코루틴이 다시 재개될 수 있음. 디스패처에서는 스레드 정책을 사용하지 않음

디스패처와 함께 필요에 따라 풀(Pool) 또는 스레드를 정의하는 데 사용할 수 있는 몇 가지 빌더

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

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

코루틴 빌더

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

  • async(): 결과가 예상되는 코루틴을 사용하는 데 사용
    코루틴 내부에서 일어나는 모든 예외를 캡처해서 결과에 넣기 때문에 조심해서 사용해야 함. 결과 또는 예외를 포함하는 Deferred<T>를 반환

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

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

async()의 예제

val result = GlobalScope.async {
  	isPalindrome(word = "Sample")
}
result.await()


디폴트 디스패처에서 async()가 실행된다.
디스패처를 수동으로 지정할 수 있다.
val result = GlobalScope.async(Dispatchers.Unconfined) {
  	isPalindrome(word = "Sample")
}
result.await()

두번 째 예에서는 Unconfined는 코루틴의 디스패처로 사용된다.

⭐정리

  • 앱에서는 하나 이상의 프로세스가 있음
    각각은 적어도 하나의 스레드를 갖고 있고 코루틴은 스레드 안에서 실행
  • 코루틴은 재개될 때마다 다른 스레드에서 실행될 수 있지만 특정 스레드에만 국한될 수도 있음.
  • 앱이 하나 이상의 스레드에 중첩돼 실행되는 경우는 동시적 실행
  • 올바른 동시성 코드를 작성하려면 서로 다른 스레드 간의 통신과 동기화 방법을 배워야 하며, 코틀린에선 코루틴의 통신과 동기화 방법의 학습을 의미
  • 병렬 처리는 동시 처리 앱이 실행되는 동안 적어도 두 개 이상의 스레드가 같이 실행될 때 발생
  • 동시 처리는 병렬 처리 없이 일어날 수 있음.
    현대적 처리 장치는 스레드 간에서 교차 배치를 할 것이고 효과적으로 스레드를 중첩시킬 것
  • 동시성 코드를 작성하는데 어려움
    -> 레이스 컨디션, 원자성 위반, 교착 상태 및 라이브락
profile
개발자희망자

0개의 댓글