[Android/Java] 안정성/무결성을 위한 Multi-Thread 동기화 방법 7가지 소개

mhyun, Park·2023년 8월 6일
1
post-custom-banner

Android 혹은 Java에서 동기화를 구현하는 것은 멀티스레드 환경에서의 안정성과 데이터 무결성을 보장하는 데 중요하다.
이번 포스팅에선 동기화를 구현하는 방법으로는 어떠한 것들이 있고 각각의 방법은 어떤 상황에서 사용하는지 구체적인 예시를 통해 알아보려고 한다.

1. Synchronized 키워드

  • 메서드나 블록을 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 안으로 진입한다.
}

2. volatile 키워드

  • 일반적으로, Main Memory에서 읽은 변수 값은 성능을 위해 각 Thread Cache에 저장하게 된다. 이로인해 Multi Thread 환경에서, 각각의 Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생할 수 있다.
  • 변수에 volatile 키워드를 사용하면, 해당 변수의 값을 항상 메인 메모리에서 읽고 쓸 수 있게끔 설정하여 컴파일러와 JVM에게 해당 변수의 값이 항상 최신 상태임을 보장한다.
    • 가시성 보장 : 한 스레드에서 변경한 값은 다른 스레드에서 즉시 반영되며, 변경된 값을 신속하게 공유할 수 있다.
    • 원자성 보장 : 한 스레드에서 volatile 변수에 대한 쓰기 연산이 수행되는 동안 다른 스레드에서는 해당 변수를 읽을 수 없다. 이로 인해 변수에 대한 여러 스레드 간의 경쟁 조건을 방지할 수 있다.
    • 순서 보장 : 컴파일러와 JVM이 코드 순서를 최적하지 않기 때문에 순서대로 실행될 것을 보장한다.
  • 복잡한 스레드 간 상호작용이 필요한 경우에는 추가적인 동기화 메커니즘을 사용해야 하기 때문에 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 이기에 읽기쓰기가 동시에 들어가기때문에 시퀀스에 대한 원자성이 보장될 수 없다.
이러한 경우는 AtomicIntegerAtomicReference 같은 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();
}

3. ReentrantLock

  • java.util.concurrent 패키지에 있는 ReentrantLock 클래스를 사용하여 동기화가 필요한 block을 세부적으로 설정한다.
  • synchronized와 사용성은 비슷하지만, lock 획득 시간을 설정하거나 획득할 수 없을 때 다른 작업을 수행하게 할 수 있다.
  • 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 getHoldCount() : 현재 Thread가 해당 ReentrantLock 객체의 lock을 몇 번 획득했는지를 반환한다.
  • ReentrantLock getQueueLength() : 현재 ReentrantLock 객체의 lock을 기다리고 있는 대기 중인 스레드의 수를 반환한다.
  • ReentrantLock getWaitQueueLength(Condition condition) : ReentrantLock이 파생시킨 Condition의 await으로 대기 중인 스레드의 수를 반환한다.

4. ReentrantLock Condition, wait 과 notify

  • ReentrantLock 객체와 연결된 Condition 조건 변수를 제공하여 await() 메서드를 사용하여 특정 조건이 만족될 때까지 스레드를 대기시키고, signal() 또는 signalAll() 메서드를 사용하여 대기중인 스레드를 깨울 수 있다.
  • producer & consumer 구조에서 안정적인 동작을 보장하기 위해 사용된다.

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();
        }
    }
}

5. CountDownLatch 클래스

  • java.Util.concurrent 패키지에서 지원하는 클래스로 특정 수의 Thread 작업이 완료될 때까지 대기하고 동기화할 수 있다.
    • 생성자 : CountDownLatch를 생성한 스레드가 기다리는 작업의 개수를 설정한다.
    • countDown() : 한 스레드에서 작업이 완료될 때마다 countDown() 메서드를 호출하여 카운트 값을 감소시킨다.
    • await() : 대기 중인 스레드에서 await() 을 호출하면, 카운트 값이 0이 될 때까지 스레드를 대기 상태로 만든다.
  • 주로 한 스레드가 다른 여러 개의 스레드가 작업을 마칠 때까지 기다려야 할 때 활용된다. ex) 초기화 완료 후 로직 수행

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
    }
}
  • CountDownLatch getCount() : 현재 CountDownLatch 의 count 상태 값을 반환한다. 해당 값을 통해서 작업이 모두 완료되었는지 혹은 작업 진행 상태를 확인할 수 있다.

6. Semaphore 클래스

  • java.util.concurrent 패키지의 Semaphore 클래스를 사용하여 동시에 접근할 수 있는 스레드 수를 제한하고 동기화를 구현할 수 있다.
  • 스레드 풀 관리 및 한정된 자원 사용 등 공유 리소스 접근을 제어하는데 주로 사용한다.
  • 정해진 개수의 허용된 스레드만 동시에 접근을 허용하도록 설정하며, 허용된 스레드 수를 초과하면, 나머지 스레드들은 대기 상태로 들어간다.
    • 생성자 : 초기 허용 스레드 수를 지정한다.
    • acquire() : 스레드가 리소스를 점유하기 위해 호출하는 메서드로 허용 스레드 수를 초과하지 않는지 확인한다.
      허용 스레드 수를 초과하면 대기 상태로 전환된다.
    • release() : 스레드가 리소스 사용을 마치면 release() 메서드를 호출하여 Semaphore의 허용 스레드 수를 증가시킨다.

private Semaphore semaphore = new Semaphore(2);

public void execute() {
    try {
        semaphore.acquire();
        // 작업 수행
        semaphore.release();
    } catch (InterruptedException e) {
        // ignore
    }
}

7. 지역 변수 활용

동기화 문제 상황

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

  • synchronized 키워드를 사용함으로써 간단히 해결할 수 있다.
  • 하지만, onProcessResult가 real-time으로 매우 많이 호출되는 구조라면 메소드가 호출될 때마다 동기화를 위한 비용이 크게 발생하게 된다.
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

  • 동기화 문제가 발생하는 전역 변수를 지역 변수로 캐스팅하여 사용한다.
  • synchronized를 사용하지 않음으로써 동기화를 위해 발생하는 값비싼 비용을 없앨 수 있다.
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에서 동기화를 구현하는 방법에 대해 알아보았다.
각 방법은 다양한 상황에서 사용되며, 필요에 따라 적절한 동기화 방법을 선택하여 멀티스레드 환경에서 안정성을 유지하는 것이 중요하다.

profile
Android Framework Developer
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 8월 6일

정보에 감사드립니다.

1개의 답글