-
📕 코틀린 동시성 프로그래밍 - Ch.1) Hello, Concurrent World!KOTLIN 2022. 11. 8. 14:04
코틀린 동시성에 대해서 공부하기 위해 '코틀린 동시성 프로그래밍 ' 책을 읽고 정리하기로 하였다.
이번엔 챕터1 부분을 읽고 정리하였다.
프로세스
프로세스란 실행되고 있는 어플리케이션의 인스턴스를 의미한다. 프로세스는 자원, 프로세스 ID 데이터, 네트워크 연결과 같은 상태를 가지고 있고 프로세스 안의 스레드는 이러한 데이터에 접근이 가능하다. 앞으로 말할 내용은 단일 프로세스안에서 여러개의 스레드를 가질 때 생길 수 있는 문제에 대해 다룬다.
스레드
스래드의 실행은 프로세스를 실행할 지시들을 포함한다. 프로세스는 기본적으로 어플리케이션을 실행할 시작점을 가진 스레드 하나를 가지는데 프로세스에 포함된 각각의 스레드는 프로세스가 가진 자원에 접근하고 변경할 수 있고 고유의 쓰레드 로컬 저장소라고 불리는 저장소를 가진다. 또한, 한번에 스레드의 하나의 실행문만 실행될 수 있어 하나의 쓰레드가 blocked 된다면 같은 프로세스의 다른 스레드들이 실행될 수 없기 때문에 blocking 작업을 수행할 때는 전용 스레드에서 작업이 수행되도록 한다. 예를 들어, GUI 어플리케이션의 경우 지속적으로 사용자에 따라 실시간 반응을 하기 위해서 UI 쓰레드를 block하지 않아야 한다. 같은 프로세스에 여러개의 쓰레드가 생성될 수 있고 서로 의사소통 할 수 있다.
코루틴
코루틴은 스레드와 비슷한 라이프사이클을 가지고 같은 역할을 수행하기 때문에 경량화된 스레드로서 이해될 수 있으며 스레드 안에서 실행된다. 실행문이 많아도 코루틴이 고정된 사이즈의 스레드 풀을 사용하여 쓰레드에게 코루틴을 나누어 실행하기 때문에 많은 양의 코루틴을 실행하더라도 아주 작은 영향만 준다. delay()를 이용해서 코루틴의 suspend된 경우 해당 코루틴이 실행되던 스레드에서는 새로 시작하거나 재개할 준비가 된 또다른 코루틴을 실행한다. 즉, 이는 코루틴이 스레드 안에서 실행되어도 코루틴이 쓰레드에 묶여이지 않다는 것을 의미하며 하나의 스레드에서 코루틴 일부를 실행하고 실행을 중지했다가 다른 쓰레드에서 해당 코루틴 실행을 이어갈 수 있다. 또한, 코틀린은 유연하기 때문에 어떤 쓰레드에서 코루틴을 실행할고 실행하지 않을 지 정할 수 있다.
+ 참고로 인텔리제이의 경우 어플리 케이션을 실행할 때 기본적으로 2가지의 스레드를 가지고 있는데, 이는 인텔리제이를 실행할때 생성되는 Monitor Contro + Break 스레드 때문이다.
동시성 문제
동시성은 둘 이상의 알고리즘 실행 시간이 겹칠때 발생하고 동시성 문제는 어플리케이션이 한번에 둘 이상의 스레드에서 실행될 때 발생한다. sequential code만을 사용하게 되는 경우 정확한 실행 순서를 알 수 있고 예측이 가능하다는 장점이 있지만 Concurrent code에 비해 성능이 떨어지고 실행되고 있는 하드웨어의 장점을 취하지 못한다는 단점이 있다. 이 때문에 동시성 코드를 작성하여 다음과 같이 suspend 함수를 사용하는 경우 메서드 안의 asyncGetUserInfo, asyncGetContactInfo 메서드는 각각 다른 스레드에서 동시에 비동기식으로 동작한다. 여기서 비동기식으로 실행될 함수의 결과로 나오는 데이터 basicUserInfo, contactInfo를 활용할 createProfile에서 메서드가 동작할 때 이미 비동기식 함수가 완료될 때까지 기다린다는 의미의 await()을 이용해 비동기식 함수가 완료되었을 때만 실행하도록 하여 예외를 방지할 수 있다.
suspend fun getProfile(id: Int) { val basicUserInfo = asyncGetUserInfo(id) val contactInfo = asyncGetContactInfo(id) createProfile(basicUserInfo.await(), contactInfo.await()) }
Concurrency(병행성) vs Parallelism(병렬성)
병행성(Concurrency)은 하나의 코어에서 여러 쓰레드를 가진 프로그램이 실행된다고 가정될 때 동시에 실행되는 것이 아니라 스레드들의 타임라인이 오버랩되도록 하여 동시에 실행되는 것처럼 보이도록 하는 것이다.
반면에, 병렬성(Parallelism)은 두개 이상의 코어에서 여러 쓰레드를 가진 프로그램을 실행할 때 실제로 여러 쓰레드를 동시에 실행시켜 각가의 쓰레드가 독립적으로 실행이 되고 타임라인이 오버랩 되는 것 뿐만 아니라 실제로 동시에 실행이 된다. parallelism을 사용하는 경우 하나의 어플리케이션을 다른 여러 컴퓨터에서 각각의 일을 하도록 실행시키는 distributed computing이 가능해진다.
+ Parallelism은 Concurrency을 가지지만, Concurrnecy은 항상 Parallelism을 가짐을 의미하지 않는다.
CPU-bound vs I/O-bound
CPU-bound 작업은 CPU 사용이 요구되는 알고리즘을 의미하며 실행되고 있는 CPU를 업그레이드 하는 것만으로도 성능을 향상 시킬 수 있다. I/O-bound 작업의 경우 input/output 장비에 의해 실행되는 알고리즘을 말하며 파일을 읽는 등의 장비의 수행 시간에 의존적이다.
Concurrency vs Parallelism in CPU-bound 알고리즘
CPU-bound 알고리즘의 경우 single-core에서 concurrency의 수행보다 parallelism하게 수행할 때 성능을 향상시킬 수 있다.
singole core의 경우 여러개의 하나의 코어에서 쓰레드를 오고가면서 context switching이 지속해서 발생하는데 전반적인 프로세스에 걸쳐서 context switching이 오버헤드를 발생시켜 sequential한 수행보다 일을 더 느리게 진행시킬 수 있다. 반면에 parallel하게 작업을 수행하는 경우에는 sequential하게 작업을 수행할 때 보다 1/n의 수행시간으로 성능을 개선할 수 있다. 단, CPU-bound 알고리즘을 수행하기 위해서 현재 장비의 코어 갯수에 따른 합리적인 쓰레드 갯수를 정하는 것이 중요한데 이는 코틀린의 CommonPool에 의해 조절될 수 있다. CommmonPool의 크기는 해당 장비의 코어 갯수 - 1이다.
Concurrency vs Parallelism in I/O-bound 알고리즘
I/O-bound 작업의 경우에는 지속적으로 무언가를 기다려야 하는 작업이 많기 때문에 single-core에서 concurrency나 parallelism을 사용하게 되면 모두 기다리는 시간을 다른작업을 수행하도록 하면서 시간을 효율적으로 사용하도록 할 수 있다.
Concurrency의 문제점
race conditions
concurrent한 코드를 작성할 때 가끔 특정한 순서대로 작동할 것이라는 가정을 하게 되는데 그렇지 못한 경우 race condition 문제가 발생할 수 있다. 예를들어 다음과 같은 코드를 실행할 때, asyncGetUserInfo가 delay(1100)으로 인해 더 오래 걸려 예상했던 순서대로 작동하지 않으며 user가 초기화 되지 않은 상태이기 때문에 어플리케이션은 중단될 것이다.
data class UserInfo(val name: String, val lastName: String, val id: Int) lateinit var user: UserInfo fun main(args: Array<String>) = runBlocking { asyncGetUserInfo(1) // Do some other operations delay(1000) println("User ${user.id} is ${user.name}") } fun asyncGetUserInfo(id: Int) = async { delay(1100) user = UserInfo(id = id, name = "Susan", lastName = "Calvin") }
Atomicity violation
원자성이란 사용하는 데이터를 향한 접근이 서로 간섭하지 않는 것을 의미하며 single-thread 어플리케이션에서는 모든 작업의 수행이 sequential하게 이루어지므로 원자성을 지킬 수 있다. multi-thread의 경우 여러개의 쓰레드가 공유자원에 대한 접근과 변경 작업이 오버래핑되므로 원자성을 해치게 된다.
Deadlocks
순환의존관계때문에 어플리케이션의 작업이 중단되는 것을 의미한다. 보통 race condition과 함께 lock들의 네크워크가 복잡하게 얽혀 발생되게 된다.
Livelocks
deadlock과 비슷하지만, livelock은 어플리케이션의 상태가 지속적으로 바뀌고 있지만 정상적인 수행을 막는 방향으로 변하는 것을 말한다. 즉 두사람이 골목에서 마주쳤다고 가정했을때 지속적으로 같은 방향으로 이동해 서로를 막고 있을 때를 말한다. 이런 상태에서 두사람은 모두 deadlock 상태를 어떻게 빠져나가야 하는지(반대 방향으로 이동해야함)를 알고 있지만 각각 회복 작업을 수행하는 타이밍이 맞지않아 서로의 진행을 막고있는 상황이다. livelock은 주로 deadlock에서부터 회복하려고 시도할 때 발생한다.
kotlin에서의 concurrency
non-blocking
스레드는 무겁고, 생산에 비용이 들고 제한되어있기 때문에 스레드가 block된 경우에는 많은 자원이 낭비되게 된다. Kotlin은 Suspendable Computations을 통해서 쓰레드의 수행을 blocking없이 작업을 수행하도록 한다. 즉, 스레드에서 하나의 작업을 일시중단 하고 다른 작업을 해당 스레드에서 수행하도록 하여 자원을 활용한다.
Explicit
Suspendable computations는 기본적으로 sequential하게 수행된다. 명시적으로 async {...}와 await()을 사용하여 코드를 concurrent하게 수행하도록 하고 결과를 이용할 수 있도록 한다.
Readable
코틀린에서의 concurrent 코드는 sequential 코드처럼 읽기가 쉽다.
Leveraged
전에 보았듯이 concurrent code에서 스레드를 생성하고 최적의 양을 스레드를 가지는 것, CPU 작업 쓰레드와 더불어 I/O 작업 쓰레드를 가지는 것, 스레드끼리 동기화 및 소통을 이루는 것 등을 관리하는 것이 중요하다. 코를린은 고차함수 및 원시타입을 이용해서 이러한 작업을 손쉽게 한다.
- newSingleThreadContext() : 새로운 스레드를 생성
- newFixedThreadPoolContext() : 스레드 풀을 생성
- CommonPool : CPU-bound 작업을 위한 스레드 풀을 말하며 최대 크기는 장비의 코어 갯수 -1이다.
- 만약 다른 쓰레드로 코루틴을 옮기는 작업이 필요하다면 runtime이 이를 수행한다.
- channels, mutexes, thread confinement 와 같은 코루틴을 동기화 하고 소통하도록 하는 원시 타입과 기술이 존재한다.
Flexible
코틀린은 다양한 방법을 통한 유연한 동시성 코드 작성이 가능하다.
- Channel : 코루틴끼리 안전하게 데이터를 보내고 받는 데 쓰이는 파이프
- Worker pools : 여러개의 쓰레드에서의 작업을 나누는데 사용되는 코루틴 풀
- Actors : 여러개의 쓰레드로부터 자원의 상태를 변경시키는 것을 안전하게 하도록 채널과 코루틴을 이용하는 상태를 감싼 wrapper
- Mutual exclusions (Mutexes) : 한번에 하나의 쓰레드만 접근하여 작업을 수행할 수 있는 임계구역을 설정하여 동기화를 수행하는 메카니즘
- Thread confinement : 코루틴의 작업이 특정 쓰레드에서만 수행할 수 있도록 제한
- Generators (Iterators and sequences) : 새로운 정보가 요구되지 않을 때 중지되거나 요구에 따라 정보를 반환하는 데이터 소스
동시성관련 용어와 개념 정리
Suspending computations
스레드를 blocking 하지 않으면서 작업을 일시중지하는 것으로 다른 computation을 위해 해당 스레드를 사용할 수 있다.
Suspending functions
suspend를 붙인 함수 형태의 suspending computations로 다른 computation을 위해 해당 스레드를 사용할 수 있다.
Suspending lambdas
일반 람다와 비슷한 익명 함수로 일반 람다와 다르게 다른 suspending functions를 부름으로서 스스로의 작업 수행을 정지할 수 있다.
coroutine dispatcher
어떤 스레드에서 코루틴을 시작하거나 재개할 지 결정하는 역할을 맡는다.
- DefaultDispatcher : 현재 CommonPool과 같다.
- CommonPool : background 스레드들의 pool에서 코루틴을 수행하거나 재개한다.
- Unconfined : 현재 스레드에서 코루틴을 시작하지만 다른 스레드에서 코루틴 작업을 재개할 수 있다.
- 이 dispatcher에는 적용되는 스레드 정책이 없다.
+ 위의 Dispatcher와 함께 pool이나 thread를 정의하기 위해서 다음과 같은 함수를 사용할 수 있다.
- newSingleThreadContext()
- 싱글 스레드와 함께 dipatcher를 build
- 여기서 수행된 코루틴들은 항상 같은 스레드에서 시작하거나 재개된다.
- newFixedThreadPoolContext()
- 주어진 크기의 쓰레드 풀과 함께 dispatcher를 시작
- runtime이 어떤 쓰레드를 이용하여 코루틴을 시작하고 재개할지 정한다.
Coroutine Builders
lambda를 중지하고 실행할 새로운 코루틴을 만드는 역할을 수행한다.
- async()
- 결과가 예상될 때 코루틴을 시작하기 위해 사용
- 결과나 예외를 담은 Deferred<T> 타입을 반환
- launch()
- 결과를 반환하지 않는 코루틴을 시작
- Job을 리턴
- runBlocking()
- suspendable한 코드에 blocking code를 연결해주는 역할
- 주로 main이나 단위테스트시 사용됨
- 현재 코루틴의 수행이 완료될 때 까지 현재 쓰레드를 block 한다.
'KOTLIN' 카테고리의 다른 글
코틀린에서 테스트 하기 (⏳) (0) 2022.11.27 📕 코틀린 동시성 프로그래밍 - Ch.2) Coroutine in Action (0) 2022.11.11 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ④ 구글 스프레드 시트 사용하기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ③ 슬랙으로 메세지, view 보내기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ② 슬랙으로 요청받기 (0) 2022.10.22