Dev

Multi-Thread 동시성 제어 방법

칼퇴시켜주세요 2024. 5. 3. 18:47
728x90

시작하기 앞서 ‘본 문서는 동시성과 관련된 용어들을 정리해 보고, 프로젝트를 진행하면서 발생했던 동시성 문제를 다양한 동시성 처리 기법들을 사용하여 해결하여 결과 비교 분석’을 위해 작성되었습니다..

동시성 관련 용어

병렬처리(Parallel Computing)

병렬처리는 간단히 말해서 여러 명령을 같은 순간에 처리하는 것입니다. 일반적으로 하나의 CPU 코어는 한 번에 하나의 명령을 처리할 수 있습니다. 만약 하나의 프로그램에서 다중 코어를 이용해 여러 명령을 한 번에 처리한다면 병렬 처리라고 할 수 있습니다. 반드시 CPU 코어를 사용해서 병렬처리를 할 수 있는 것은 아니고, 수천 개의 코어로 이루어진 GPU를 활용하여 병렬처리를 하는 방법도 있습니다.

멀티쓰레딩(Multithreading)

OS가 제공하는 쓰레드를 이용하여 병렬처리를 하는 기법입니다. 일반적으로 하나의 프로그램은 하나 이상의 프로세스들로 이루어져 있고, 프로세스는 하나 이상의 쓰레드로 이루어져 있습니다.

(프로그램>프로세스>쓰레드)

하나의 쓰레드는 로컬 변수들을 저장할 스택 메모리와 실행 중인 명령의 위치 등으로 이루어져 있습니다. 같은 프로세스 내 쓰레드는 서로 힙 메모리를 공유합니다.

동시성 프로그래밍(Concurrent Programming)

동시성 프로그래밍은 기존의 순서대로 명령들이 실행되는 구조에서 벗어나 명령들이 불규칙한 순서대로 실행될 수 있게 해주는 프로그래밍 기법입니다. 멀티쓰레딩은 동시성 프로그래밍의 한 방법이지만, 동시성 프로그래밍이 꼭 병렬처리 및 멀티쓰레딩을 의미하지는 않습니다.

비동기 프로그래밍(Asynchronous Programming)

비동기 프로그래밍은 Future, Promise 및 Async-Await와 같은 추상화된 기능들을 이용하여 동시성 프로그래밍을 하는 방법입니다. 마찬가지로 비동기 프로그래밍을 사용한다고 해서 꼭 병렬처리 및 멀티 쓰레딩인 것은 아닙니다.

동시성 문제 예제

아래 코드는 치명적인 문제를 가지고 있는데, 첫 번째는 동기화 문제 두 번째는 데이터 정합성 문제입니다

@Component
public class SessionStore {
  // key : userId
  // value : session string
  private final HashMap<Long, String> memorySession = new HashMap<>();

  // CRUD method ...
}

동기화 문제

동기화 문제를 이야기하려면 멀티 쓰레딩에 대한 배경지식이 필요합니다. 싱글톤 패턴은 어플리케이션에서 특정 클래스의 인스턴스가 딱 하나만 생성되도록 하는 패턴입니다. 만약 멀티 쓰레드 환경이라면 싱글톤 객체가 여러 쓰레드에 의해서 동시에 접근되는 경우가 발생하고 공유 자원에 대한 경쟁 상태가 발생할 수 있습니다.

웹 어플리케이션 환경으로 해석하면 WAS(Tomcat)의 여러 쓰레드가 싱글톤 빈에 접근하고 공유 자원에 대한 경쟁 상태가 발생한다고 말할 수 있습니다.

public class MapTest {
  Map<Integer, Integer> map = new HashMap<>();

  @Test
  void map_test() {
    CompletionException completionException = assertThrows(CompletionException.class, () -> {
      CompletableFuture[] futures = IntStream.range(0, 10)
          .mapToObj(idx -> CompletableFuture.runAsync(() -> increase(idx)))
          .toArray(CompletableFuture[]::new);
      CompletableFuture.allOf(futures).join();
    });

    assertTrue(completionException.getCause() instanceof ConcurrentModificationException);
  }

  private void increase(int key) {
    for (int i = 0; i < 10000; i++)
      map.compute(key, (k, v) -> v == null ? 1 : v + 1);
  }
}

위 코드는 매우 높은 확률로 예외가 발생합니다. 자바에서는 여러 쓰레드가 동시에 한 컬렉션을 변경하려고 하면 ConcurrentModificationException이 발생하게 되어있습니다. 이러한 동시성 문제를 방지하기 위해 concurrentHashMap 혹은 synchronized 키워드 사용을 권장하고 있습니다.
https://velog.io/@alsgus92/ConcurrentHashMap%EC%9D%98-Thread-safe-%EC%9B%90%EB%A6%AC

 

[Java] ConcurrentHashMap는 어떻게 Thread-safe 한가?

ConcurrentHashMap이란? ConcurrentHashMap은 Java 1.5 버전에서 HashTable의 대안으로 처음 소개된 Collection이다. ConcurrentHashMap이 나오기전까진 multi-thread에서 map을 thread-safe하게

velog.io

https://devlog-wjdrbs96.tistory.com/269

 

[Java] ConcurrentHashMap 이란 무엇일까?

들어가기 전에 HashTable, HashMap, ConcurrnetHashMap은 많이 유사한 특징들을 가지고 있습니다. 하지만 세부적으로 보면 조금씩 꽤나 차이가 있는데요. 간단하게 어떤 차이가 있는지 알아보면서 시작하

devlog-wjdrbs96.tistory.com

데이터 정합성 문제

데이터 정합성 문제는 여러 대의 WAS를 운영하는 경우 발생하게 됩니다. 예를 들어, 운영하는 서비스가 흥행에 성공해서 한 대의 WAS로는 트래픽을 감당할 수 없어서 다수의 WAS로 증설하는 경우, 임의의 한 WAS에서 인증을 마치면 해당 WAS는 세션 정보를 저장하고 있지만 다른 WAS는 유저의 세션 정보를 알 수 없습니다. 요청이 어떤 WAS로 전달될지 알 수 없으니 재수 없다면 매 요청마다 인증에서 문제가 발생할 수 있습니다. 이처럼 여러 대의 WAS를 운영하면서 동시에 싱글톤 빈에 상태를 저장한다면 데이터 정합성이 깨져도 문제가 없는 데이터를 유지해야 합니다.

실제로 발견된 동시성 문제

아래 코드를 살펴보면 AttFileInfo 존재 유무를 확인하고 있으면 반환, 없으면 생성 작업을 처리하는 로직입니다. 만약 단일 쓰레드에서 해당 메서드를 실행한다면 문제가 없지만, 멀티 쓰레드로 해당 메서드를 실행한다면 동시성 문제 race condition이 발생합니다.

public AttFileInfo createAttFileInfo(Long fileIdx) {
  //insert할때 테이블이 없으면 내부에서 프로시저 호출을 통해 데이터를 채워준다.
  AttFileInfo attFileInfo;
  Optional<AttFileInfo> optionalAttFileInfo = attFileInfoRepository.findByFileIdx(fileIdx);

  //DB에 파일의 존재 확인
  if (optionalAttFileInfo.isPresent()) {
    return optionalAttFileInfo.get();
  } else {
    Long objectTableIdx = attFileInfoRepository.createAttObjectTableIdx();
    attFileInfo = AttFileInfo.builder()
        .idx(null)
        .fileIdx(fileIdx)
        .objectTableIdx(objectTableIdx)
        .build();
    attFileInfo = attFileInfoRepository.insertAttFileInfo(attFileInfo); //DB 에 FileInfo 생성
  }
  return attFileInfo;
}

ReentrantLock

아래 코드를 살펴보면 메서드 시작과 끝사에어 lock을 사용하여 단일 쓰레드가 해당 메서드를 수행 할 수 있도록 강제 하였습니다.

문제점

  • 한번에 한개의 쓰레드만 해당 메서드를 수행 할 수 있기 때문에 여러개의 요청이 동시에 들어왔을때 낮은 처리량을 가지게 되어 성능이 안좋아지게 됩니다.
public AttFileInfo createAttFileInfo(Long fileIdx) {
  lock.lock();
  //insert할때 테이블이 없으면 내부에서 프로시저 호출을 통해 데이터를 채워준다.
  AttFileInfo attFileInfo;
  Optional<AttFileInfo> optionalAttFileInfo = attFileInfoRepository.findByFileIdx(fileIdx);

  //DB에 파일의 존재 확인
  if (optionalAttFileInfo.isPresent()) {
    return optionalAttFileInfo.get();
  } else {
    Long objectTableIdx = attFileInfoRepository.createAttObjectTableIdx();
    attFileInfo = AttFileInfo.builder()
        .idx(null)
        .fileIdx(fileIdx)
        .objectTableIdx(objectTableIdx)
        .build();
    attFileInfo = attFileInfoRepository.insertAttFileInfo(attFileInfo); //DB 에 FileInfo 생성
  }
  lock.unlock();
  return attFileInfo;
}

Double Check Spin Lock

아래 코드는 기본 spin-lock 개념에 Double-Check 개념을 섞어 attFileInfo인스턴스 쓰레드 동기화 작업을 진행한 결과입니다. while문을 돌면서 atomicBoolean 상태를 확인하고, while 전후로 attFileInfo 정보를 조회하여 변경된 내용을 쓰레드 동기화 시킵니다.

문제점

  • 실제로 lock을 걸지 않아 OS context switching이 발생하지 않지만 며러개 쓰레드 중 한개의 쓰레드 동작이 완전히 종료되때 까지 나머지 쓰레드들은 while문에서 대기하고 있게 됩니다.
  • context switching 오버헤드는 발생하지 않지만 하나의 작업이 완전히 끝날때 까지 while문으로 대기하고 있기 때문에 resource가 빨리 릴리즈되어 spinlock으로 낭비되는 CPU time이 context switching으로 인한 latency보다 크다면 다른 방법이 필요합니다.
//Double-check lock
public void createOrUpdateAttObject1(Long attFileIdx, File localSavedFile) {
  List<AttObject> attObjectList = attObjectFactory.attFileReader(attFileIdx, localSavedFile);

  for (AttObject attObject : attObjectList) {
    log.info("{}", attObject.getObjName());
  }

  log.info("[Success] {} - file parsing!", localSavedFile.getName());

  //오브젝트 DB 업데이트 후 각각의 오브젝트들이 어떤 파티셔닝 테이블에 있는지 조회하고 결과를 바탕으로 FileInfo 테이블 생성
  // 해당 줄을 실행할때 스핀락을 통해 attFileInfo값 변경사항을 체크
  AttFileInfo attFileInfo = attFileInfoService.getAttFileInfo(attFileIdx);
  if (attFileInfo == null) {
    while (!lock1.compareAndSet(false, true)) {
      Thread.yield();
    }
    try {
      attFileInfo = attFileInfoService.getAttFileInfo(attFileIdx);
      if (attFileInfo == null) {
        try {
          //procedure 호출을 통해 저장할 테이블 Idx를 구하고 AttFileInfo 객체 생성
          attFileInfo = attFileInfoService.createAttFileInfo(attFileIdx);
        } catch (RuntimeException e) {
          throw new IllegalStateException("Failed to create AttFileInfo", e);
        }
      }
    } finally {
      lock1.set(false);
    }
  }
  //파일의 object들이 저장되어있는 테이블 번호를 확인하고 해당 테이블에 fileIdx를 가진 모든 레코드를 조회한 후 해당 오브젝트를 벌크 삭제
  List<AttObject> attObjectListForDelete = attObjectRepository.findAttObjectByFileIdx(
      attFileIdx, attFileInfo.getObjectTableIdx());
      /*
        //파일의 object들이 저장되어있는 테이블 번호를 확인하고 해당 테이블에 fileIdx를 가진 모든 레코드를 삭제하고 새롭게 추가 -> 이 것의 조건은 하나의 테이블에 한개의 파일 오브젝트만 존재한다고 가정할때
        attObjectRepository.deleteAttObject(attFileInfo.getFileIdx(), attFileInfo.getObjectTableIdx());
      */
  attObjectRepository.deleteAttObject(attObjectListForDelete,
      attFileInfo.getObjectTableIdx());
  //새롭게 받은 데이블에 object 추가
  attObjectRepository.insertAttObject(attObjectList, attFileInfo.getObjectTableIdx());
}

Lock-Free

아래 코드는 lock-free를 구현한 코드입니다. 기본적으로 lock-free를 적용하기 위해 AtomicReference를 사용하여 인스턴스 reference 자체를 atomic하게 관리하여 각각의 쓰레드가 reference에 접근 하려고 할때 CAS 알고리즘에 의해 동기화를 진행합니다.

문제점

  • 생산성 : 알고리즘이 복잡해집니다.
  • 신뢰성 : 알고리즘의 정확성을 증명하는 것이 어렵습니다.
  • 확장성 : 새로운 메서드를 추가하는 것이 어렵습니다.
  • 메모리 : 메모리 재사용이 어렵고, ABA 문제가 발생할 수 있습니다.

Sample(1)

public AttFileInfo createAttFileInfo1(Long fileIdx) {
    //insert할때 테이블이 없으면 내부에서 프로시저 호출을 통해 데이터를 채워준다.
    AttFileInfo attFileInfo = attFileInfoRef.get();

    if (attFileInfo == null) {
      AttFileInfo currentAttFileInfo = attFileInfoRepository.findByFileIdx(fileIdx).orElse(null);
      if (currentAttFileInfo == null) {
        Long objectTableIdx = attFileInfoRepository.createAttObjectTableIdx();
        currentAttFileInfo = AttFileInfo.builder()
            .idx(null)
            .fileIdx(fileIdx)
            .objectTableIdx(objectTableIdx)
            .build();
      }

      if (attFileInfoRef.compareAndSet(null, currentAttFileInfo)) {
        attFileInfo = attFileInfoRepository.insertAttFileInfo(currentAttFileInfo);
      } else {
        attFileInfo = attFileInfoRef.get();
      }
    } else {
      attFileInfo = attFileInfoRef.get();
    }
    return attFileInfo;
  }

Sample(2)

public AttFileInfo createAttFileInfo1(Long fileIdx) {
  while (true) {
    AtomicReference<AttFileInfo> attFileInfoRef = fileInfoMap.computeIfAbsent(fileIdx,
        k -> new AtomicReference<>());
    AttFileInfo attFileInfo = attFileInfoRef.get();

    if (Objects.isNull(attFileInfo)) {
      AttFileInfo currentAttFileInfo = attFileInfoRepository.findByFileIdx(fileIdx).orElse(null);
      if (Objects.isNull(currentAttFileInfo)) {
        Long objectTableIdx = attFileInfoRepository.createAttObjectTableIdx();
        currentAttFileInfo = AttFileInfo.builder()
            .idx(null)
            .fileIdx(fileIdx)
            .objectTableIdx(objectTableIdx)
            .build();
      }

      if (attFileInfoRef.compareAndSet(null, currentAttFileInfo)) {
        attFileInfo = attFileInfoRepository.insertAttFileInfo(currentAttFileInfo);
      } else {
        attFileInfo = attFileInfoRef.get();
      }
    } else {
      AttFileInfo currentAttFileInfo = attFileInfoRepository.findByFileIdx(fileIdx).orElse(null);
      if (attFileInfoRef.compareAndSet(attFileInfo, currentAttFileInfo)) {
        attFileInfo = attFileInfoRef.get();
      }
    }
    return attFileInfo;
  }
}

https://tech.devsisters.com/posts/programming-languages-5-concurrent-programming/

 

9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍

프로그래밍 언어들을 비교해가며 동시성 프로그래밍을 알아보고 더 나은 코드를 작성하는 방법을 고민해봅니다.

tech.devsisters.com

https://devlog-wjdrbs96.tistory.com/269

 

[Java] ConcurrentHashMap 이란 무엇일까?

들어가기 전에 HashTable, HashMap, ConcurrnetHashMap은 많이 유사한 특징들을 가지고 있습니다. 하지만 세부적으로 보면 조금씩 꽤나 차이가 있는데요. 간단하게 어떤 차이가 있는지 알아보면서 시작하

devlog-wjdrbs96.tistory.com

https://kangworld.tistory.com/326

 

[Spring] Bean을 stateless하게 설계해야하는 이유

서론 최근에 인턴 면접관으로 참여할 일이 생겨 지원자들이 제출한 코드를 훑어보던 중 재밌는 코드를 발견했습니다. @Component public class SessionStore { // key : userId // value : session string private final Hash

kangworld.tistory.com

https://effectivesquid.tistory.com/entry/Lock-Free-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98Non-Blocking-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98

 

Lock Free 알고리즘(Non-Blocking 알고리즘)

병렬 알고리즘과 관련해서 최근의 연구 결과를 보면 대부분이 Non-Blocking 알고리즘, 즉 여러 스레드가 동작하는 환경에서 데이터의 안정성을 보장하는 방법으로 락을 사용하는 대신 저수준의 하

effectivesquid.tistory.com

https://2jun0.tistory.com/57

 

synchronized, CAS, ABA

멀티 스레드 환경에서는 공유 자원을 신경 써줘야 한다. 왜 그럴까? 아래를 보자. public class Tests { private static int count; @Test void nonMutualExclusion() throws InterruptedException { new Thread(() -> { for (int i = 0; i <

2jun0.tistory.com

 

반응형