Kotlin Coroutine - (1-2) CPU 바운드와 I/O 바운드, 동시성 프로그래밍의 어려운점.
병목현상은 다양한 유형의 성능저하가 발생하는 지점을 나타낸다. (병목은 갈수록 좁아진다.)
따라서 애플리케이션의 성능을 최적화 할때 가장 중요한 사항이다.
동시성과 병렬성이 CPU나 I/O 연산에 바운딩 됐는지 여부에 따라 성능에 어떻게 영향을 미치는지 알아보자.
CPU 바운드와 I/O 바운드 란?
CPU 만 완료되면 되는 작업을 중심으로 구현되는 알고리즘을 CPU 바운드 라고 한다.
이러한 경우에는 알고리즘 성능이 CPU성능에 좌우된다.
간단한 예시로 아래 코드를 보자.
fun filterPalindromes(words: List<String>) : List<String> {
return ...//좌우가 같은 단어를 반환한다.
}
val words = listOf("level","pope","needle","Anna",...)
fun main(args: Array<String>) {
filterPalindromes(words).forEach {
println(it)
}
}
단어를 가져와서 좌우가 같은 단어인지 판단하고, 프린트해주는 코드이다.
이 예제에서 실행되는 모든 부분은 CPU성능에 의존적이다. (words에 숫자가 많아지면 성능이 느려지고, CPU성능이 올라가면 빨라진다)
I/O 바운드는 CPU바운드와 반대로, 입출력 장치에 의존하는 알고리즘이다.
I/O 바운드는 입출력장치의 속도에 의존하게 되는데, 위 예시 코드에서 words를 외부 문서에서 읽어오는 알고리즘이 되면 I/O 바운드다.
fun main(args: Array<String>) {
val words = readWordFromJson("resources/words.json")
filterPalindromes(words).forEach {
println(it)
}
}
예시로 파일이 HDD에 있으면 느리고, SSD에 있으면 빨라진다.
이런 디스크에 저장된게 아닌, 네트워킹이나 컴퓨터 주변기기로 입력 받는것도 I/O 작업이다.
I/O 바운드 알고리즘은 I/O 작업을 기준으로 성능에 대한 병목 현상을 일으키는데, 최적화가 외부 시스템이나 장치에 의존한다는걸 의미한다.
CPU바운드 알고리즘에서의 동시성과 병렬성
CPU바운드 알고리즘의 성능은 CPU에 의존하기 때문에 다중코어에서 병렬성을 활용하면 성능을 향상시키지만, 단일 코어에서 동시성을 구현하면 성능이 저하되기도 한다.
예시로 위에 좌우가 같은 단어를 필터링하는 알고리즘에 1000개의 단어당 하나의 스레드가 생성되도록 처리하고, 3000개의 단어를 넣었다고 생각해보자.
단일코어에서는
하나의 코어가 3개의 스레드 사이에서 교차배치되며, 매번 일정량의 단어를 필터링하고 다음 스레드로 전환될것이다.
이렇게 전환되는걸 컨텍스트 스위칭이라고 한다. ( OS 수업시간에 많이 들었음 )
컨텍스트 스위칭은 현재 상태를 저장하고 다음 스레드의 상태를 불러와야 하기때문에, 전체 프로세스에 오버헤드가 발생한다.
오버헤드란 어떤 처리를 하기 위해 들어가는 간접적인 처리 시간 · 메모리를 말함.
예로 10초 걸릴작업이 안정성을 고려해서 15초가 걸린다면 5초의 오버헤드가 발생했다고 한다.
따라서 다중 스레드로 구현하면, 단일 코어 머신에서는 더 오래걸릴 가능성이있다.
단일 스레드에서는 컨텍스트 스위칭이 발생하지 않기 때문이다.
다중코어(병렬 실행)에서는
병렬실행의 경우, 각 스레드가 하나의 전용 코어에서 실행된다고 가정하면, 실행속도는 3분의 1로 줄어들 것이다. 각 코어에서 중단할 필요없이 1000개의 단어를 필터링 하면 되기 때문이다.
따라서 CPU 바운드 알고리즘을 위해서는 현재 사용중인 CPU의 코어 수를 기준으로 적절한 스레드 수를 생성하도록 고려해야한다.
이렇게 하면 CPU 바운드 알고리즘을 실행하기 위해 생성된 스레드 풀인 코틀린의 CommonPool을 활용할 수 있다.
CommonPool의 크기는 CPU 코어 수에서 1을 뺀 값이다. 4개의 코어가 있다면 크기는 3.
I/O 바운드 알고리즘에서의 동시성 대 병렬성
I/O 바운드 알고리즘은 무언갈 끊임없이 기다린다. 이렇게 계속 기다리는건, 단일 코어 기기에서 대기중에 다른 유용한 작업에 프로세스를 사용할 수있도록 한다. 따라서 I/O 바운드인 동시성 알고리즘은 코어 수에 상관없이 유사하게 수행될것이다.
I/O 바운드 알고리즘은, 순차적인 알고리즘보다 동시성 구현에서 훨씬 더 나은 성능 향상을 발휘하므로, I/O작업은 동시성으로 실행하는게 좋다. 그리고, GUI애플리케이션에서는 UI스레드를 블록하지 않는것이 매우매우 중요하다. (애초에 비동기 처리를 안하면 안됨 ㅋㅋ)
동시성이 어려운 이유
동시성 코드를 제대로 작성하는건 좀처럼 쉽지않다. (그래서 내가 이책을 리뷰하는 이유...)
아래 나열하는 다양한 문제들이 있기 때문이다...
레이스 컨디션
동시성코드를 작성할 때 가장 흔한 오류는 레이스 컨디션이다.
이 경우는 동시성으로 작성했지만, 코드가 순차적으로 동작할 것으로 예상할 때 발생한다. (항상 특정한 순서로 동작할거라고 오해할때 생김)
예를 들어 데이터베이스에서 데이터를 가져오고 웹서비스를 호출하는 기능을 동시에 수행하는 코드를 작성중이라고 가정하면, 많은 사람들이 가장 흔히 하는 실수로, 데이터베이스가 더 빠를것으로 가정하고 웹서비스 작업이 끝나자 마자 데이터베이스 작업의 결과에 접근하려는 것이다. 하지만 데이터베이스 작업이 웹서비스보다 오래걸리면 오류가 발생할 것이다.
lateinit var user : UserInfo
fun main(args:Array<String>) = runBlocking{
asyncGetUserInfo(1)
delay(1000) //1초간 대기 후에 프린트한다.
println(user)
}
fun asyncGetUserInfo(id:Int) = async {
delay(1100) // 하지만 여기서 1.1초 대기를 한 후에 초기화를 해준다면?
user = UserInfo(id = id, name = ~~)
}
위 예시를 보면 main에서 1초 대기 후 user데이터를 print하는데. asyncGetUserInfo에서 1.1초를 대기하게 되면 오류가 발생할 것이다.따라서 레이스컨디션을 고치려면, 정보에 접근하려고 하기 전에 정보를 얻을 때까지 명시적으로 대기를 해주어야 한다.
원자성 위반
원자성 작업이란 작업이 사용하는 데이터를 간섭 없이 접근할 수 있음을 말한다.
단일 스레드 앱에서는 모든 코드가 순차적으로 진행되므로, 모든 작업이 모두 원자이다. 스레드가 하나라 간섭이 있을 수 없다.
원자성은 객체의 상태가 동시에 수정될 수 있을때 필요하고, 그 상태의 수정이 겹치지 않도록 보장해야한다. 수정이 겹치게 되면 데이터손실이 발생할 수 있기 때문이다.
교착 상태
동시성 코드가 올바르게 동기화 되려면 다른 스레드에서 작업이 완료되는동안 실행을 일시 중단할 필요가 있다. 이러한 상황의 복잡성 때문에, 전체 애플리케이션이 중단되는 상황이 종종 있다.
만약JobA의 작업을 위해 JobB를 기다려야하는데 JobB에서도 JobA의 값을 대기해야는 경우가 발생하게 되는게 교착 상태이다.
위 예시는 간단해서 그런일이 발생할 일은 없지만, 일반적으로 복잡한 관계에서 발생하며, 레이스 컨디션처럼 자주 발생한다.
또한 레이스 컨디션은 교착 상태가 발생할 수 있는 예기치 않은 상태를 만들기도 한다.
라이브 락
라이브락은 교착상태와 유사하다. 라이브 락이 진행될 때 애플리케이션의 상태는 지속적으로 변하지만 정상 실행으로 돌아오지 못하는 방향으로 변한다는 점이 다르다.
만약 A와 B가 서로 좁은 복도에서 마주쳐서, 서로가 한쪽 방향으로 이동해서 피하려고 하는데, 계속 서로 같은 방향으로 이동해서 서로의 길을 막는것을 생각하면 아주 쉽다. 보통 서로 교착상태를 가는것을 피하기 위해 이동하는것인데, 이런 회복이 오히려 진행을 방해하게 된다.
따라서 교착상태를 복구하려다 라이브락이 발생하는 경우가 많다.