이 글은 스터디 후기는 아니고, 앞으로 책을 읽으며 정리한 내용과 스터디를 진행하며 추가적으로 알게된 내용을 기록하고자 한다 !
넥스터즈 라는 동아리를 작년 여름 17기부터 현재까지 해오고 있다. ( 개인적으로 참 많은 걸 배우고 성장하고 자극받을 수 있었다고 생각해서 강추 ! )
이번 7-8월에 진행되는 19기 활동도 참여하고 있는데, 이번 기수에 '코틀린 동시성 프로그래밍' 이라는 기술 서적 스터디가 개설되었다 ! 사실 그 동안 나는 Rx보다 오히려 코루틴을 더 많이 사용해왔고, 그 과정에서 Kotlin, Android 공식 문서나 그 외의 레퍼런스도 많이 읽고 공부를 했지만 같이 프로젝트를 진행하는 팀원이 코루틴이 처음이라고 설명해달라고 하자 명확하게 설명을 할 수가 없었다.🤯 그래서 이 기회에 확실하게 코루틴을 알아보자는 마음에서 스터디에 참여하게 되었다.
책이 약간 번역이 어색한 부분은 당연히 있지만 공식문서나 공식문서를 번역한 타 블로그들보다 설명이 자세히 이해하기 쉽게 되어있었고, 그럼에도 명확하게 이해가 안되고 그림이 그려지지 않는 설명은 스터디를 진행하며 질문도하고 다른 스터디원의 질문에 대답도 하며 조금씩 더 그림이 그려지는 것 같았기 때문이다 ! 완독했을 때 코루틴을 완전하게 이해할 나를 기대하며 🤓
간단히 이 포스팅이 시작된 얘기를 해보았다. 이제부터 작성할 내용은 1장에 대한 내용이다. 1장에서는 동시성을 논하기 위한 개념 정리 그리고 코틀린이 동시성을 다루는 방법에 대한 개요를 담고있다.
프로세스 : 실행중인 애플리케이션의 인스턴스
멀티 프로세스는 이 책에서 다루지 않음. 즉 멀티 스레드 환경만을 다룬다는 이야기
스레드 : 프로세스 내의 명령 실행 단위
코루틴 : 경량 스레드 (lightweight thread)
코루틴을 lightweight thread라고 하는 것이 별로 좋지않은 것 같다는 스터디원의 의견이 있었다.
코루틴이 실제로는 object이기 때문에 혼란을 줄 수 있는 워딩이기 때문이라고 설명해주었다.
동시성은 어플리케이션이 동시에 하나 이상의 스레드에서 실행될 때 발생된다. 즉 동시성이 발생하려면 두 개 이상의 스레드가 생성되어야 한다. 또한 동시성 상황에서 스레드간의 통신 및 동기화는 필수적이다 !
동시성이란 입력에 대해서 늘 최종적으로 같은 결과를 내놓지만 그 과정의 실행순서는 유연성이 있는 것을 의미한다
동시성을 사용하는 이유 ? 성능 !!!! 효율성 !!!! 속도든 자원이든 !!!!
동시성은 두 개 이상의 실행이 겹쳐질 때 발생한다. 두 개 이상의 실행 스레드가 필요하다. 하지만 단일 코어에서 실행되면 실제로 병렬적으로 실행될 수는 없다. 단지 스레드들의 실행이 효율적이도록 교차 배치하는 것이다.
병렬은 두 개 이상의 실행이 같은 시점에 실행되는 것이다. 즉 두 개 이상의 스레드가 필요할 뿐만 아니라 두 개 이상의 코어도 필요하다.
1. CPU바운드 : CPU 연산 작업을 중심으로 이루어진 알고리즘은 CPU 성능에 의존적
2. I/O 바운드 : read/write 같은 입출력 연산으로 이루어진 알고리즘은 외부 시스템이나 장치에 의존적
( 하드드라이브에 저장되었는지 SSD에 저장되었는지, 네트워킹이나 주변기기로 입력을 받는 경우 등 )
1. 레이스 컨디션 : 동시성으로 작성한 코드가 항상 특정한 순서로 동작할 것이라고 가정하면 발생하는 문제로 동시성 코드 일부가 제대로 작동하기 위해 일정한 순서로 완료돼야 할 때 발생한다.
( 이것은 동시성 코드를 구현하는 올바른 방법이 아니다 ! )
data class UserInfo(val name : String, val lastName : String, val id : Int)
lateinit val user : Userinfo
fun main(args : Array<String>){
asyncGetUserInfo(1)
// Do some other operation
delay(1000)
println("User ${user.id} is ${user.name}")
}
fun asyncGetUserInfo(id : Int){
user = UserInfo(id = id, name = "Susan", lastName = "Calvin")
}
위의 예시 코드는 유저 정보를 할당하는 코드가 프린트보다 먼저 수행되는 것이 보장돼야만 하는 코이다. delay(1000)으로 문제 없이 동작하는 것 처럼 보이지만, 만일 아래와 같이 함수를 수정한다면 문제가 발생할 것이다.
fun asyncGetUserInfo(id : Int){
delay(1100)
user = UserInfo(id = id, name = "Susan", lastName = "Calvin")
}
2. 원자성 위반
val counter = 0
fun main(args : Array<String>){
val workerA = asyncIncrement(2000)
val workerB = asyncIncrement(100)
workerA.await()
workerB.await()
print("counter : $counter ")
}
fun asyncIncrement(by : Int){
for(i in 0 until by){
counter++
}
}
위의 결과는 2100이어야 할 것 같지만, 2100보다 작은 값을 출력하기도 한다. 그런 경우는 아래의 그림과 같은 상황으로 원자성이 위반된 경우인 것이다.
3. 교착 상태 : 동기화를 위해 다른 스레드의 작업이 완료되는 동안 실행을 일시 중단하거나 차단하는 등의 의존이 발생할 때, 순환적 의존이 발생하여 애플리케이션의 실행이 중단되는 상황이 발생할 수 있다.
lateinit val jobA : Job
lateinit val jobB : Job
fun main(args : Array<String>){
jobA = GlobalScope.launch{
delay(1000)
jobB.join()
}
jobB = GlobalScope.launch{
jobA.join()
}
jobA.join()
println("finished")
}
위와 같은 코드를 살펴보면 jobA의 스코프가 실행되고 잠시 delay하는 동안 jobB 스코프가 실행된다. 이 때, jobA는 jobB를 대기하고, jobB는 jobA를 대기하느라 서로 종료되지 못하는 경우가 발생한다. main 역시 jobA를 대기하고 있기 때문에 모든 흐름이 다 종료되지 못하고 무한 대기가 발생하는 것이다.
4. 라이브 락 : 애플리케이션의 상태는 지속적으로 변하지만 정상 실행으로 돌아오지는 못하는 교착상태
책에서는 라이브 락을 이해하기 쉽게 복도에서 두 사람이 마주쳤을 때, 서로 피하려고 같은 방향으로 계속해서 움직이는 경우의 예를 들고 있다. 이 예시를 흐름도로 보면 아래와 같다.
코틀린에서의 동시성의 특징들은 아래와 같다 ! 특히 논블로킹 특성이 중요하다고 생각한다 !
1. 논블로킹 💡
: 코틀린에서는 suspendable computation을 통해 스레드의 실행은 블로킹하지 않으면서 실행을 잠시 중단할 수 있다.
2. 기본형 활용
코틀린에서는 동시성 코드를 쉽게 구현할 수 있는 고급 함수와 기본형을 제공한다.
이 외에도 유연하게 동시성을 사용하게끔 도와주는 기본형들이 존재하는데, 앞으로 책에서 다룰 주제들이라고 생각하면 좋을 것 같다.
모두 뒤의 장에서 더 자세히 나오고, 1장에서는 간단하게 느낌과 개념만 잡고 넘어가는 ~
1. 일시 중단 연산 : 해당 스레드를 차단하지 않고 실행을 일시 중단하는 연산. 해당 스레드는 다른 연산에 할당
2. 일시 중단 함수 : 함수 형태의 일시 중단 연산. (내부 동작 원리는 뒤에 나옴 ! 9장 !)
suspend fun greetAfter(name : String, delayMillis : Long){
delay(delayMillis)
println("Hello, $name")
}
위의 함수는 delay가 호출될 때, 일시 중단된다. 또한 아래에서 볼 수 있듯이 delay를 까보면 이도 suspend function임을 알 수 있다. 그러므로 일시중단되는 동안 스레드는 다른 연산을 수행하는 데에 사용될 수 있으며, delay가 완료되면 greetAfter의 실행이 이어진다.
public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
3. 람다 일시 중단 : 다른 일시 중단 함수를 호출할 수 있고, 자신의 실행을 중단할 수 있는 람다
4. 코루틴 디스패처 : 코루틴을 시작하거나 재개할 스레드를 결정하는 역할
CoroutineDispatcher 인터페이스 구현
디스패처 종류
1. DefaultDispatcher : 말 그대로 기본 디스패처, 현재는 CommonPool이고 바뀔 수 있음
2. CommonPool : 공유된 백그라운드 스레드풀에서 코루틴을 실행하고 다시 시작한다.
3. Unconfined : 현재 스레드에서 코루틴을 시작하지만 어떤 스레드에서도 코루틴이 재개될 수 있음.
디스패처와 풀/스레드를 생성하는 빌더
1. newSingleThreadContext() : 단일 스레드로 디스패처 생성, 여기에서 실행되는 코루틴은 항상 같은 스레드에서 시작 및 재개
2. newFixedThreadContext() : 지정된 크기의 스레드풀이 있는 디스패처 생성, 디스패처에서 실행된 코루틴을 시작 및 재개할 스레드 결정은 런타임이
5. 코루틴 빌더 : 코루틴을 생성 및 실행
대표적으로 async의 예제를 살펴보면 아래와 같고, 아래에서 볼 수 있듯이 디스패처를 수동으로 지정할 수도 있다.
val result = GlobalScope.async{
isPalindrome(word = "Sample")
}
result.await()
val result = GlobalScope.async(Dispatchers.Unconfined){
isPalindrome(word = "Sample")
}
result.await()