안드로이드에서 흔히 MutableStateFlow의 상태값을 변경할 때 setValue 혹은 update 함수를 통해 상태값을 변경한다.
private val _person = MutableStateFlow(Person("Kim", 20))
val person: StateFlow<Person> = _person.asStateFlow()
fun setPerson() {
_person.value = Person("Lee", 30) // setValue
}
fun updatePerson() {
_person.update { it.copy(age = it.age + 1) } // update
}
그런데 언제 setValue를 사용해야 하고 언제 update 함수를 사용해야 할까?
class Counter {
private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
val count = _count.asStateFlow()
// setValue로 상태를 업데이트
fun incrementWithSetValue() {
_count.value = _count.value + 1
}
// update를 사용하여 상태를 업데이트
fun incrementWithUpdate() {
_count.update { it + 1 }
}
}
여기 간단한 상태를 업데이트하는 코드가 있다. 초깃값은 0이며 setValue로 1씩 증가시키는 함수와 update로 1씩 증가시키는 함수 이렇게 2가지가 존재한다.
class CounterUnitTest {
@Test
fun `setValue로 동시성 문제 발생`() = runBlocking {
val counter = Counter()
val expectedIncrements = 1000 // 증가 횟수
// 여러 코루틴이 동시에 상태를 증가시키는 작업 실행
coroutineScope {
repeat(expectedIncrements) {
launch(Dispatchers.Default) {
counter.incrementWithSetValue()
}
}
}
println("setValue 최종값: ${counter.count.value}")
// 최종 count 값이 expectedIncrements와 일치하는지 확인
assertEquals(expectedIncrements, counter.count.value)
}
@Test
fun `update 사용으로 동시성 문제 해결`() = runBlocking {
val viewModel = Counter()
val expectedIncrements = 1000 // 증가 횟수
// 여러 코루틴이 동시에 상태를 증가시키는 작업 실행
coroutineScope {
repeat(expectedIncrements) {
launch(Dispatchers.Default) {
viewModel.incrementWithUpdate()
}
}
}
println("update 최종값: ${viewModel.count.value}")
// 최종 count 값이 expectedIncrements와 정확히 일치하는지 확인
assertEquals(expectedIncrements, viewModel.count.value)
}
}
/*
setValue 최종값: 963
update 최종값: 1000
*/
여러 코루틴에서 동시에 상태를 증가시키는 작업을 1000번 진행하는 코드이다. count 상태값을 1을 증가시키는 단순한 작업이라서 1000개의 코루틴이 동시에 실행되지는 않겠지만 분명히 여러 개의 스레드에서 여러 개의 코루틴이 동시에 실행되는 시점이 존재한다.
코드는 유사하지만 하나는 setValue를 실행하는 함수를 사용하였고 다른 하나는 update를 실행하는 함수를 사용하였다.
출력되는 결과는 setValue 최종값이 963으로 출력되고 update 최종값이 1000으로 출력되어 첫번째 테스트는 failed, 두번째 테스트는 passed가 된다. (setValue 최종값은 매번 달라질 수 있다.)
똑같이 1을 증가시키는 함수인데 왜 이런 일이 발생하는 것인지 내부 코드를 살펴보자.
/**
* Creates a [MutableStateFlow] with the given initial [value].
*/
@Suppress("FunctionName")
public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> = StateFlowImpl(value ?: NULL)
MutableStateFlow 객체를 생성할 때 우리가 사용하는 MutableStateFlow(value: T) 함수는 StateFlowImpl 객체이다.
private class StateFlowImpl<T>(
initialState: Any // T | NULL
) : AbstractSharedFlow<StateFlowSlot>(), MutableStateFlow<T>, CancellableFlow<T>, FusibleFlow<T> {
private val _state = atomic(initialState) // T | NULL
private var sequence = 0 // serializes updates, value update is in process when sequence is odd
@Suppress("UNCHECKED_CAST")
public override var value: T
get() = NULL.unbox(_state.value)
set(value) { updateState(null, value ?: NULL) }
override fun compareAndSet(expect: T, update: T): Boolean =
updateState(expect ?: NULL, update ?: NULL)
private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence: Int
var curSlots: Array<StateFlowSlot?>? // benign race, we will not use it
synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
_state.value = newState
//...
}
// ...
}
}
setValue와 update함수 모두 결국엔 updateState 함수를 실행하게 되는데 이 함수의 주요 코드 흐름을 따라가보자.
1. synchronized(this) 블록 덕분에 한 번에 하나의 스레드에서만 블록 내의 코드를 실행할 수 있다.
2. oldState는 현재 상태값을 가져온다.
3. expectedState != null && oldState != expectedState 이 조건문을 충족하면 아무것도 하지 않고 false를 리턴한다.
4. 이전 상태와 새로운 상태의 값이 같다면 oldState == newState 이 조건문을 충족하기 때문에 아무것도 하지 않고 true를 리턴한다.
5. 하지만 oldState와 newState가 다르다면, 즉 새로운 상태라면 기존 상태값을 새로운 상태로 초기화한다.
그럼 아까 예시 코드에서 왜 1000이 최종값으로 나오지 않고 963이 최종값으로 출력되었는지 알아보자.
먼저 _count.value = _count.value + 1 이 코드를 실행할 떄, 우변의 _count.value에서 get을 하고 1을 더한 뒤 _count.value = _count.value + 1을 통해 set을 한다는 것을 인지하자.
예를 들어 현재의 상태값이 0이고 두 개의 스레드에서 두 개의 코루틴이 동시에 _count.value = _count.value + 1를 실행한다고 가정하자. 그럼 우변에서 get을 할 때의 상태값은 둘다 0이 된다. 그리고 두 개의 코루틴이 1로 새로운 값을 설정하려고 할 것이다.
public override var value: T
get() = NULL.unbox(_state.value)
set(value) { updateState(null, value ?: NULL) }
private fun updateState(expectedState: Any?, newState: Any): Boolean {
// ...
synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
_state.value = newState
//...
}
// ...
}
set(1) { updateState(null, 1) }을 실행한다.expectedState != null && oldState != expectedState 조건문을 충족하지 못하여 그냥 다음 코드가 실행된다.set(1) { updateState(null, 1) }을 실행한다.oldState == newState 조건문이 충족하기 때문에 아무것도 하지 않는다.이런 시나리오가 발생할 가능성이 있기 때문에 두 개의 코루틴이 실행되었는데도 값을 증가하는 데 손실이 발생한 것이다.
이 시나리오는 잘 일어나지는 않지만 1000번을 시도했을 때 실제로는 963이 되어 37번의 증가가 손실이 일어난 것처럼, 동시에 상태에 접근하는 시도가 많아질수록, 그리고 코드가 복잡할수록 손실이 일어날 가능성은 늘어난다.
suspend fun incrementWithSetValue() {
delay(100)
_count.value = _count.value + 1
}
실제로 incrementWithSetValue에 delay를 추가하였더니 결과가 931이 출력되어서 손실이 더 많이 발생했다는 것을 확인할 수 있었다.
/**
* Updates the [MutableStateFlow.value] atomically using the specified [function] of its value.
*
* [function] may be evaluated multiple times, if [value] is being concurrently updated.
*/
public inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
while (true) {
val prevValue = value
val nextValue = function(prevValue)
if (compareAndSet(prevValue, nextValue)) {
return
}
}
}
update 함수는 내부적으로 compareAndSet을 통해 새로운 상태로 업데이트한다. compareAndSet의 결과가 true를 반환할 때까지 while 반복문을 통해 계속 값을 업데이트하려는 시도를 한다.
override fun compareAndSet(expect: T, update: T): Boolean =
updateState(expect ?: NULL, update ?: NULL)
private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence: Int
var curSlots: Array<StateFlowSlot?>? // benign race, we will not use it
synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
_state.value = newState
//...
}
//...
}
_count.update { it + 1 }을 실행한다.compareAndSet(0, 1)이 호출되고 updateState(0, 1)이 호출된다.expectedState != null && oldState != expectedState 조건문을 충족하지 못하여 그냥 다음 코드가 실행된다._count.update { it + 1 }을 실행한다.expectedState != null && oldState != expectedState 조건문을 충족하여 아무것도 하지 않고 false를 반환한다.comparAndSet의 결과가 false이기 때문에 update 함수의 while문을 통해 다시 compareAndSet 함수를 호출한다. 이때는 compareAndSet(1, 2)를 호출한다.expectedState != null && oldState != expectedState 조건문을 충족하지 못하여 다음 코드가 실행된다.이러한 과정을 통해 update는 데이터 변경의 손실을 막는다. expectedState를 전달하여 expectedState != null && oldState != expectedState 조건문을 통해 데이터가 이미 업데이트되었는지의 검증과정을 거치는 것이다. 검증하여 이미 데이터가 업데이트되었으면 한번 더 업데이트를 시도하는 것이다.
update 함수를 호출했을 때 내부적으로 compareAndSet 함수를 호출했다. compareAndSet이라는 이름이 붙은 이유가 있다.
update 함수처럼 동시성 프로그래밍에서 원자적(atomic)으로 값을 업데이트하는 기법을 CAS(compare and set) 기법이라고 한다.
기본 동작 원리는 다음과 같다.
1. 현재 값 읽기 : 먼저 특정 메모리 위치에 저장된 현재 값을 읽는다.
2. 예상 값과 비교 : 그 값이 우리가 예상한(expected) 값과 일치하는지 확인한다. (예상값은 새로운 값이 아닌 현재값을 예상하는 값이다)
3. 일치하면 업데이트 : 만약 현재 값과 예상 값이 같다면, 새로운 값(new value)으로 업데이트한다.
4. 불일치하면 실패 : 만약 두 값이 다르다면, 업데이트를 수행하지 않고 실패(false)를 반환한다.
용어 정리
원자적(atomic) : 더 이상 쪼개질 수 없다는 의미그럼 Atomic한 작업이라는 것은? : 더 이상 쪼개질 수 없는 작업 -> 한 번 시작하면 끝까지 완료되어야 한다 -> 다른 작업이 이 작업을 방해할 수 없다 -> 멀티 스레드 프로그래밍, 코루틴 등 동시성 프로그래밍에서 안전하다
만약 원자적으로 값을 업데이트한다면 동시성 프로그래밍에서 안전하게 값을 업데이트할 수 있다는 의미이다.
public inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) { ... }
update 함수의 function 파라미터를 보면 람다 인자를 전달하는 형태인 것을 알 수 있다.
IDE에서도 기본 람다 인자 it이 전달되는 것을 확인 가능하다.
그래서 만약 새로운 상태값이 현재 상태값에 의존하는 경우에는 update 함수를 사용하는 것이 더 적합하다. 하지만 현재 상태값에 의존하는 경우가 아닌 아예 새로운 값을 설정하는 경우에는 setValue를 통해 새로운 값으로 설정하는 것이 더 적합하다.
여러 코루틴 혹은 스레드가 MutableStateFlow의 상태를 업데이트 해야 하거나 혹은 새로운 상태값이 현재 상태값에 의존하는 경우에는 update, 그런 경우가 아니라면 setValue를 통해 업데이트하는 것이 더 효율적이다.