Kotlin 동시성 프로그래밍
동기식 프로그래밍
초기 컴퓨터의 문제
초기 컴퓨터는 하나의 프로그램만을 실행시키는 역할을 했다. 그래서 초기 시분할 시스템에서는 각 프로세스가 가상적인 폰 노이만 컴퓨터 였다. 폰 노이만 컴퓨터 답게 각각 명령어와 데이터를 저장하는 메모리 공간을 가지고 기계어로 된 명령어를 순차적으로 수행하며, 운영체제가 제공하는 I/O 수단을 통해 외부와 교류했다.
위와 같은 이유로 초기 컴퓨터의 문제점은 다양했다. 하나의 문제를 뽑자면, 외부 입출력과 같은 작업이 지속되면 프로그램이 블락(Block)되어 시간적으로 비효율적일 수 밖에 없었다.
스레드의 등장
위와 같은 문제점을 해결하기 위한 방법론 중 하나로 스레드 가 등장하게 되었다. 스레드는 PC(프로그램 카운터), 스택, 지역 변수등을 가지고, 공유해야 할 변수는 프로세스 내 힙(Heap) 지역에 할당하여 이용한다. 이로 인해 하나의 프로그램 내에서 여러 스레드를 실행시켜 작업을 좀 더 효율적으로 할 수 있게 되었다. 초기에는 스레드를 경량 프로세스(lightweight process) 라고 불렀다. 지금의 코루틴이 경량 스레드(lightweight thread) 라고 불리는 이유와 비슷하다.
스레드의 등장으로 인해 기존에는 프로세스 내에서의 흐름이 하나뿐이였다면 이제 다수의 흐름(Flow) 로 바뀌게 되었다.
동시성 프로그래밍에서의 문제
class A {
var a: Int = 0
fun plus() {
a++
}
}
fun main() {
val aobj = A()
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
Thread.sleep(1000)
println("${aobj.a}")
}
위 코드를 멀티 스레드 환경에서 실행했을때의 문제점은 무엇일까? 동시성 프로그래밍을 해봤다면 알겠지만 ++
연산은 원자적 연산이 아니다. 즉, 값의 ++
연산 도중에 다른 스레드에서 값을 읽게 되면 읽어온 값은 +1 이 더해진 1
이 아니라 0
으로 나오게 된다.
이러한 문제는 하나의 프로그램을 여러 흐름이 실행시키는 문제로 인해 발생한다. 즉, 멀티-스레드 프로그래밍을 하게 되면 자연스럽게 발생하는 문제이다. 이로 인해 멀티-스레드 프로그래밍을 진행할때 공유되는 변수에 관한 적절한 처리가 필요하다.
Synchronized
class A {
var a: Int = 0
@Synchronized fun plus() {
a++
}
}
동시성 문제가 발생하게 되면 여러 해결방법이 있지만 간단하게는 @synchronized
를 붙이는 방법이 있다.
class A {
var a: Int = 0
@Synchronized fun plus() {
a++
}
}
fun main() {
val aobj = A()
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
Thread.sleep(1000)
println("${aobj.a}")
}
위와 같이 코드를 돌린뒤 실행해보면 순서대로 잘 처리되는것을 확인할 수 있다. Sychronized 처리를 한 함수는 암묵적인 락(intrinsic lock) 을 획득한 것과 같으며 코드로 보자면 아래와 같다.
class A {
var a: Int = 0
fun plus() {
synchronized(this) {
a++
}
}
}
fun main() {
val aobj = A()
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
}
Thread.sleep(1000)
println("${aobj.a}")
}
객체 자체를 Lock 객체로써 활용하는 것이다. Java 자체에서 Mutex 로 이용되는 것이며 하나의 스레드만 Lock 객체를 소유할 수 있다.
재진입성(reentrant)
재진입성은 단어자체만 보면 어렵지만 이야기하자면, 스레드 단위로 락을 다시획득할 수 있게 해주는 것이다. 아래 예시 코드를 한번 보자.
open class A {
var a: Int = 0
@Synchronized fun plus() {
a++
}
}
class B: A() {
@Synchronized fun justPlus() {
super.plus()
}
}
위와 같은 코드가 있을때 B 객체를 만든뒤 justPlus()
를 실행하게 되면 첫번째로 A 에 대한 Lock 을 취득한다. 그 뒤 super.plus()
를 호출할때 또 Lock 을 취득하려고 하지만 Lock 은 해제되지 않은 상태로 교착상태(DeadLock) 에 빠진다.
위와 같은 상황을 방지하기 위해 똑같은 스레드가 모니터 락을 이미 지니고 있을때라면 그대로 재진입(re-entry) 할 수 있도록 해주는 것 이다.
Volatile
보통 스레드가 연산을 시작하는 경우 성능 최적화를 위해 레지스터 내/외부에 값을 캐싱한다. 보통 레지스터의 경우 각 코어에서만 공유하므로 스레드마다 캐싱된 값이 다를 수 있다. Volatile 을 우리가 붙여주게 되면 JVM 은 이 변수를 캐싱하지 않아야 한다고 이해하기 때문에 항상 최신의 상태의 값을 읽을 수 있다.
다만 Volatile 은 최신의 값을 읽어온다는 “메모리 가시성” 측면에서 의미가 있는것이지 Synchronized 와 동일하게 생각해서는 안된다. 예를 들면, 위의 코드를 Volatile 을 쓴다고 해도 ThreadSafe 하지 않다.
open class A {
@Volatile
var b: Int = 0
fun plus() {
b++
}
}
fun main() {
val aobj = A()
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
println("Done1!")
}
thread(start = true, isDaemon = true) {
var i = 0
while (i < 100000) {
aobj.plus()
i++
}
println("Done2!")
}
Thread.sleep(1000)
println("${aobj.b}")
}
보통 그래서 volatile 과 같은 코드는 임계 구역에서 탈출하기 위해 플래그(Flag) 값들을 다른 스레드에서 바꿀때 해당 플래그 값이 잘 바뀌었는지 확인할 때 많이 사용된다.
참조
http://www.yes24.com/Product/Goods/3015162