Android 혹은 Java에서 동기화를 구현하는 것은 멀티스레드 환경에서의 안정성과 데이터 무결성을 보장하는 데 중요하다.
이번 포스팅에선 동기화를 구현하는 방법으로는 어떠한 것들이 있고 각각의 방법은 어떤 상황에서 사용하는지 구체적인 예시를 통해 알아보려고 한다.
synchronized
키워드로 감싸면 해당 코드 블록은 단일 스레드만 실행하도록 보장된다.synchronized
블록이 끝날 때까지 대기하며 순차적으로 수행된다.method synchronized
public synchronized void initialize() {
// 동기화가 필요한 작업 수행
}
public synchronized void execute() {
// 동기화가 필요한 작업 수행
}
public synchronized void deinitialize() {
// 동기화가 필요한 작업 수행
}
code block synchronized
private final Object lockObject = new Object();
public void initialize() {
synchronized (lockObject) {
// 동기화가 필요한 작업 수행
}
}
public void execute() {
synchronized (lockObject) {
// 동기화가 필요한 작업 수행
}
}
public void deinitialize() {
synchronized (lockObject) {
// 동기화가 필요한 작업 수행
}
}
synchronized
키워드를 사용하는 동기화 처리는 가장 쉬운 방법이지만, 무분별하게 사용하다간 디버깅하기 무지 까다로운.. 데드락이 발생할 수 있으며 코드를 재설계해야하는 대참사를 발생시킬 수 있다.
데드락이 발생하지 않기 위해 synchronized
block 안엔 최대한 다른 synchronized
block 를 두지 않도록 설계하도록 하자.
데드락 예시
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
// thread2의 synchronized (lock2) block이 끝나길 무한히 기다린다.
synchronized (lock2) {
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
// thread1의 synchronized (lock1) block이 끝나길 무한히 기다린다.
synchronized (lock1) {
}
}
});
thread1.start(); // synchronized (lock1) block 안으로 진입한다.
thread2.start(); // synchronized (lock2) block 안으로 진입한다.
}
volatile
키워드를 사용하면, 해당 변수의 값을 항상 메인 메모리에서 읽고 쓸 수 있게끔 설정하여 컴파일러와 JVM에게 해당 변수의 값이 항상 최신 상태임을 보장한다.volatile
변수에 대한 쓰기 연산이 수행되는 동안 다른 스레드에서는 해당 변수를 읽을 수 없다. 이로 인해 변수에 대한 여러 스레드 간의 경쟁 조건을 방지할 수 있다.volatile
변수는 단순히 변수의 값을 읽고 쓰는 경우에만 사용하는 것을 권장되며 반드시 1개의 Thread에서만 write 하는 환경에서 사용해야 한다.volatile 예시
private volatile boolean flag = false;
public void setFlag(boolean value) {
this.flag = value;
}
public boolean getFlag() {
return flag;
}
하지만, Inteager 를 사용하여 i += 1 과 같은 증분 연산이 필요한 경우
i = i + 1 이기에 읽기와 쓰기가 동시에 들어가기때문에 시퀀스에 대한 원자성이 보장될 수 없다.
이러한 경우는 AtomicInteger
및 AtomicReference
같은 Atomic 클래스를 활용하여 volatile
보다 다양한 작업을 원자성을 가진 형태로 운용할 수 있다.
AtomicInteger 예시
private final AtomicInteger count = new AtomicInteger();
private void process(int i) {
// process some job
count.incrementAndGet();
}
public int getCount() {
return this.count.get();
}
ReentrantLock
클래스를 사용하여 동기화가 필요한 block을 세부적으로 설정한다.synchronized
와 사용성은 비슷하지만, lock 획득 시간을 설정하거나 획득할 수 없을 때 다른 작업을 수행하게 할 수 있다.try-finally
구문을 관습적으로 사용한다.
ReentRantLock.lock 예제
private final ReentrantLock lock = new ReentrantLock();
public void initialize() {
lock.lock();
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
}
public void execute() {
lock.lock();
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
}
public void deinitialize() {
lock.lock();
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
}
ReentRantLock.tryLock 예제
private final ReentrantLock lock = new ReentrantLock();
public void initialize() {
if (lock.tryLock()) {
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
} else {
// lock 획득에 실패한 경우의 작업 수행
}
}
public void execute() {
// initialize 완료 될 때까지 1초간 lock 획득을 위해 기다린다.
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
} else {
// lock 획득에 실패한 경우의 작업 수행
}
}
public void deinitialize() {
if (lock.tryLock()) {
try {
// 동기화가 필요한 작업 수행
} finally {
lock.unlock();
}
} else {
// lock 획득에 실패한 경우의 작업 수행
}
}
또한, ReentrantLock이 제공하는 다음과 같은 메서드를 통해 ReentrantLock 객체의 상태를 모니터링하고 디버깅할 수 있어 데드락 분석 시 유용하게 사용될 수 있다.
ReentrantLock
객체와 연결된 Condition
조건 변수를 제공하여 await()
메서드를 사용하여 특정 조건이 만족될 때까지 스레드를 대기시키고, signal()
또는 signalAll()
메서드를 사용하여 대기중인 스레드를 깨울 수 있다.
public class BoundedBuffer {
private final String[] buffer;
private final int capacity;
private int front;
private int rear;
private int count;
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
public BoundedBuffer(int capacity) {
this.buffer = new String[capacity];
this.capacity = capacity;
}
public void deposit(String data) throws InterruptedException {
lock.lock();
try {
while (count == capacity) {
// condition.signal()이 호출될 때까지 대기한다.
condition.await();
}
buffer[rear] = data;
rear = (rear + 1) % capacity;
count++;
} finally {
lock.unlock();
}
}
public String fetch() throws InterruptedException {
lock.lock();
try {
if (count == 0) {
return null;
}
String result = buffer[front];
front = (front + 1) % capacity;
count--;
// condition 이 충족되었음을 signal()을 통해 알린다.
condition.signal();
return result;
} finally {
lock.unlock();
}
}
}
CountDownLatch
를 생성한 스레드가 기다리는 작업의 개수를 설정한다.private final CountDownLatch countDownLatch = new CountDownLatch(1);
// Worker Thread
public void initialize() {
// 초기화 작업 수행
...
countDownLatch.countDown();
}
// Another Worker Thread
public void execute() {
try {
// 특정 수의 작업이 완료될 때까지 대기
countDownLatch.await();
// 모든 작업이 완료되었으므로 후속 작업 진행
} catch (InterruptedException e) {
// ignore
}
}
하지만 위의 코드의 문제점은 initialize()를 수행한 쓰레드가 작업을 완료하지 못하면 countDown() 호출하지 못하기 때문에 execute()를 호출한 thread가 무한히 기다리게 된다는 것이다.
이러한 경우를 사전에 방지하기 위해 countDownLatch await() 호출 시 timeout을 함께 지정하도록 권장하고 있다.
public void execute() {
try {
// 특정 수의 작업이 완료될 때까지 대기하거나 5초 timeout 제한 설정
boolean result = countDownLatch.await(5, TimeUnit.SECONDS);
if (!result) {
throw new RuntimeException("countDownLatch is timed out!");
}
} catch (InterruptedException e) {
// ignore
}
}
private Semaphore semaphore = new Semaphore(2);
public void execute() {
try {
semaphore.acquire();
// 작업 수행
semaphore.release();
} catch (InterruptedException e) {
// ignore
}
}
동기화 문제 상황
private Callback callback;
// Main Thread에서 수행되는 method
public void setCallback(@Nullable Callback callback) {
this.callback = callback;
}
// Worker Thread에서 수행되는 method
public void onProcessResult() {
if (callback == null) {
return;
}
// 앞선 null-checking 로직에서 null이 아니기에 return되지 않았지만
// 타이밍 이슈로 해당 시점에 null인 상황이 발생할 수 있다.
callback.onProcessResult();
}
동기화 해결 방안 1
private Callback callback;
// Main Thread에서 수행되는 method
public synchronized void setCallback(@Nullable Callback callback) {
this.callback = callback;
}
// Worker Thread에서 수행되는 method
public synchronized void onProcessResult() {
if (callback == null) {
return;
}
callback.onProcessResult();
}
*동기화 해결 방안 2
private Callback callback;
// Main Thread에서 수행되는 method
public void setCallback(@Nullable Callback callback) {
this.callback = callback;
}
// Worker Thread에서 수행되는 method
public void onProcessResult() {
final Callback localCallback = callback;
if (localCallback == null) {
return;
}
localCallback.onProcessResult();
}
이렇게 Android와 Java에서 동기화를 구현하는 방법에 대해 알아보았다.
각 방법은 다양한 상황에서 사용되며, 필요에 따라 적절한 동기화 방법을 선택하여 멀티스레드 환경에서 안정성을 유지하는 것이 중요하다.
정보에 감사드립니다.