7 min read

신입 개발자가 만난 동시성 문제들 2

Arcus의 Java Client는 간략하게 아래와 같은 메커니즘으로 인해 동작한다.

  1. 응용 WAS의 api 호출
  2. Java Client에서 해당 api에 알맞은 Operation 인스턴스 생성
  3. 생성된 Operation 인스턴스를 Queue에 삽입
  4. 하나의 IO Thread(이벤트 루프)가 해당 Queue로부터 Operation 인스턴스를 pop하고 Buffer에 요청을 Write하고 Cache Server로 명령어 전송
  5. Buffer에 적힌 응답을 파싱하여 결과 기록
  6. 응용 WAS가 리턴받은 Future의 get() 호출 시 결과 리턴

1번에서 3번은 WAS의 worker 쓰레드가 수행하고, 4번에서 5번까지는 Java Client의 IO 쓰레드가 수행한다.이후 6번은 또 다시 WAS의 worker 쓰레드가 수행한다.

이번 글의 주제는 Future의 cancel() 호출 시, 발생할 수 있는 동시성 문제에 대해 다룬다.사실 다른 Future 구현체들의 cancel()을 호출 시에는 위와 같은 동시성 문제가 발생하지 않을 수 있다.하지만 Arcus Java-Client의 경우 사용자가 호출하는 Future.cancel()과 내부에서 사용되는 Operation 객체의 cancel() 로직이 동일하기에 IO 쓰레드와 WAS의 worker 쓰레드간의 경쟁 상태가 발생할 수 있어 동시성 문제 발생 가능성이 존재한다.

구체적인 상황을 예로 들어보겠다.

Future.cancel() vs Operation.cancel()

Arcus Java-Client에서 제공하는 Future.cancel()의 경우 내부적으로 Future의 Field로 가지고 있는 Operation 인스턴스의 cancel()을 호출한다. 구현은 아래와 같다.

// Future의 cancel -> single op
public boolean cancel(boolean ign) {
    assert op != null : "No operation";
    if (op.getState() == OperationState.COMPLETE) {
      return false;
    }
    op.cancel("by application.");
    return true;
  }

위과 같이 사용자에 의해 발생하는 cancel() 외에도 다른 한 종류의 cancel()이 존재한다. IO 쓰레드가 4번에서 5번 로직을 수행하며 발생시키는 cancel()이다. 이는 아래의 Operatioin 인스턴스의 cancel()을 곧바로 호출한다.

//Operation Instance의 cancel
public final void cancel(String cause) {
    cancelled = true;
    if (handlingNode != null) {
      cancelCause = "Cancelled (" + cause + " : (" + handlingNode.getNodeName() + ")" + ")";
    } else {
      cancelCause = "Cancelled (" + cause + ")";
    }
    wasCancelled();
    callback.complete();
  }

그렇다면 WAS의 Worker 쓰레드가 첫번째 경우와 같이 Future를 통해 cancel() 호출하고, 이와 동시에 IO 쓰레드가 내부적으로 Operation의 cancel() 로직을 호출하면 어떻게 될까?

바로 위의 cancel() 로직이 동시에 수행된다. 참고로 callback.complete() 로직은 CountDownLatch를 countDown()만 수행해주는 로직이다.즉, 동일한 Operation 인스턴스에 대해 latch.countDown()을 여러번 수행하여도 별 다른 문제가 발생하지 않는다.

그러면, piped 연산을 한번 고려해보자. 쉽게 말해 하나의 연산에 여러개의 Operation 인스턴스가 존재하는 경우이다. 이러한 API들의 Future.cancel()은 아래와 같다. 반복문을 통해 모든 Operation 인스턴스들의 cancel()을 호출해준다.

// Future의 cancel -> multi op
public boolean cancel(boolean ign) {
        boolean rv = false;
        for (Operation op : ops) {
          if (op.getState() == OperationState.COMPLETE) {
      return false;
    }
    op.cancel("by application.");
        }
        return rv;
      }

위와 같은 상황은 다음과 같은 부정확한 동작을 야기시킨다. 총 10개의 Operation 인스턴스가 존재하는 API가 호출되었다. 여기서 사용자가 먼저 Future의 cancel() 호출하여 반복문을 통해 6번째 Operation 인스턴스의 cancel()을 호출해주고 있다. 동시에 IO 쓰레드가 5번째 Operation의 cancel을 내부적으로 호출하였다. 하지만 이미 5번째 Operation은 외부 요청에 의해 취소가 되었고, IO 쓰레드는 5번째가 아닌 6번째 이후의 Operation 인스턴스에 대한 latch.countDown()을 진행시켜 준다.

즉, 정확한 취소 동작이 발생하지 않는다. 물론 latch.countDown()을 Operation 인스턴스 횟수 이상으로 수행해도 동일한 결과를 받을 수 있다. 하지만 위와 같은 동작이 추후 부작용을 발생시킬 수 있기 때문에 수정해야한다고 생각했다.

Future.cancel() vs transitionState(COMPLETE)

두번째 문제는 내부적으로 해당 Operation 인스턴스를 완료 처리하는 로직과 외부에서 cancel() 요청이 발생한 경우가 처리될 때 발생 가능한 동시성 문제이다.

IO 쓰레드는 4번에서 5번 동작을 수행하며 이를 정상적으로 처리할 경우 COMPLETE라는 state로 Operation의 상태를 변경시킨다. 해당 동작은 아래의 transitionState(COMPLETE) 함수 호출을 통해 이루어진다.

protected final void transitionState(OperationState newState) {
    getLogger().debug("Transitioned state from %s to %s", state, newState);
    state = newState;
    // Discard our buffer when we no longer need it.
    if (state != OperationState.WRITE_QUEUED &&
        state != OperationState.WRITING) {
      cmd = null;
    }
    if (state == OperationState.COMPLETE) {
      callback.complete();
    }
  }

아래와 같이 Future의 cancel()이 호출되었을 경우도 Operation의 상태값이 COMPLETE 인지 아닌지 확인하는 로직이 존재한다.

public boolean cancel(boolean ign) {
    assert op != null : "No operation";
    if (op.getState() == OperationState.COMPLETE) {
      return false;
    }
    op.cancel("by application.");
    return true;
  }

두 가지 메서드가 모두 state를 Read하는 다음과 같은 상황을 가정해보자.

  1. IO 쓰레드가 연산 완료 처리를 위해 transitionState(COMPLETE) 로직 수행 -> log.debug까지 수행 후 Context Switching
  2. Worker 쓰레드가 Future.get() 로직 수행 -> cancelled = true까지 수행 후 Context Switching
  3. IO 쓰레드가 transitionState(COMPLETE) 로직 완료
  4. Worker 쓰레드가 Future.get() 로직 완료

이후 사용자 Future.get()을 통해 결과를 받아올 경우, transitionState(COMPLETE) 가 먼저 호출이 되었으니 정상적인 결과를 받아야한다. 하지만, get 메서드 내부의 아래와 같은 로직으로 인해 ExecutionException이 발생한다.

 if (op != null && op.isCancelled()) {
      throw new ExecutionException(new RuntimeException(op.getCancelCause()));
    }

즉, 완료가 된 Operation임에도 불구하고 사용자로부터의 cancel() 호출이 정상적으로 수행 된다. 그 결과로 인해 Future.get()을 호출할 때, 예외가 발생하게 된다.

이렇게 해서 동시성 문제가 발생할 수 있는 두 가지 경우를 알아보았고 지금부터는 이를 해결하기 위해 어떠한 변경을 했는지 알아보자.

동시성 문제 해결하기

우선 크게 두 가지 사항을 변경하였다.

  1. Operation의 cancel() 메서드에서 boolean 리턴
  2. AtomicBoolean 사용

기존에는 Future.cancel() 메서드에서 COMPLETE 여부를 확인하고 이를 통해 불리언 값을 리턴하는 로직이였다. 첫번째 변경 사항을 통해 Operation의 cancel()을 호출 할 경우 곧바로 boolean 값을 리턴받도록 변경한다. 이러한 변경은 callbacked라는 AtomicBoolean을 통해 로직을 제어하기 위한 변경이다.

두번째 변경사항은 동시성 로직을 제어하는 AtomicBoolean을 추가하였다. 아래의 callbacked 변수는 CAS 연산을 통해 callback.complete()라는 로직이 수행이 되었는지 확인해준다. 한번이라도 callback.complete();가 호출될 경우 다른 메서드의 호출을 제어해준다.


private final AtomicBoolean callbacked = new AtomicBoolean(false);


public final boolean cancel(String cause) {
    if (callbacked.compareAndSet(false, true)) {
      cancelled = true;
      if (handlingNode != null) {
        cause += " @ " + handlingNode.getNodeName();
      }
      cancelCause = "Cancelled (" + cause + ")";
      wasCancelled();
      callback.complete();
      return true;
    }
    return false;
  }


protected final void transitionState(OperationState newState) {
    getLogger().debug("Transitioned state from %s to %s", state, newState);
    state = newState;
    // Discard our buffer when we no longer need it.
    if (state != OperationState.WRITE_QUEUED &&
        state != OperationState.WRITING) {
      cmd = null;
    }
    if (state == OperationState.COMPLETE &&
            callbacked.compareAndSet(false, true)) {
      callback.complete();
    }
  }

결론적으로는 하나의 AtomicBoolean을 통해 동시성 제어가 가능하다.하지만 이러한 결론을 얻기까지 syncronized, volatile, atomic 객체들까지 다양한 제어 도구들을 시도해보았다. 그 과정에서 단순히 제어 도구를 사용하는 것보다 논리적으로 어떠한 부분에서 동시성 이슈가 발생할 수 있는지 예상해보는것이 중요하다는 것을 알게 되었다.