Project : RainMind 개발일지 - 5 [Lock 및 동시성 제어]
FastAPI 코드를 작성하며 async await 키워드를 많이 사용했었는데, Lock 및 동시성 제어 파트에서도 Spring과 비교하여 정리를 해보면 좋을 것 같다.
Spring - Lock
1) synchronized
어떤 자원에 동시에 접근하여 쓰기 연산을 수행하면, 결과의 일부가 소실될 수도 있고 예기치 못한 동작이 발생할 수 있다.
스레드가 여러 개일때 이러한 문제를 해결하기 위해 synchronized 키워드를 사용하여 특정 자원에 대한 lock을 획득할 수 있으며 해당 lock을 해제하기 전까지 다른 스레드의 접근을 막을 수 있다.
@Transactional
public synchronized void operationService(...) {
// do something
}
그러나 위는 실패한다. 이유는 선언형 방식의 트랜잭션은 AOP 기반 proxy 객체를 통해 동작하기 때문이다.
원래 객체를 상속해서 생성된 proxy 객체에 의해 트랜잭션이 열린 후 -> 실제 메서드를 호출 -> 메서드 종료 시 트랜잭션을 commit하게 된다. 이때 synchronized는 실제 메서드에 진입할 때 lock을 걸고 푸는 역할이다. 즉, synchronized는 원본 클래스에서 프록시 클래스로 상속이 되지 않아(즉, lock이 프록시 외부가 아니라 실제 메서드 진입 시점에 적용) 트랜잭션 commit과 lock 해제 사이의 간격에 다른 스레드가 침투할 수 있다.
@Transactional을 제거하면 동작 자체는 올바르게 진행된다(lock과 commit 사이의 간격이 없어짐). 그러나 어노테이션을 지워버리면 장애 발생 시 롤백이 되지 않아 원자성을 상실하게 된다. 동시성 잡겠다고 원자성을 버리는 것이다.
아니면 lock 해제를 commit 이후로 옮기는 방법도 있다. 외부에 함수를 하나 더 만들어 감싸는 방식도 가능하다.
예를 들어 a 함수는 synchronized, b 함수는 transaction 연산을 수행한다고 할때 a 내부에서 b를 호출하면 된다. 다만 모든 트랜잭션 연산마다 바깥에 함수를 하나 더 만들면 코드 복잡도가 올라간다.
2) Reentrant lock
Synchronized의 진화 버전으로, 개발자가 직접 lock과 unlock을 명시하여 synchronized보다 더욱 세밀한 제어가 가능하다.
ReentrantLock lock = new ReentrantLock();
public void lockAndUnlock(...) {
lock.lock();
// do something
lock.unlock();
}
다만 synchronized와 같은 원리로, @Transactional과 같이 쓰면 동작이 망가진다. 자원에 락을 걸어 한번에 하나의 스레드만 진입하게 하는 핵심 기능은 비슷하다.
3) @Async
이 친구는 Lock 관련한 어노테이션은 아니고, 어떠한 메서드 위에 붙어서 메서드를 별도의 스레드에서 실행하게 해주는 어노테이션이다. 오히려 동시성 문제를 일으킬 수 있어서 따로 정리해 보는 것이다.
해당 어노테이션이 붙은 함수를 호출하면, 그 호출을 또 proxy 객체가 가로채서 해당 작업을 큐에 넣기만 하고 바로 caller로 return한다. 즉 non blocking 방식이다. 이후 해당 작업을 수행할 새로운 스레드를 만들고(기본 옵션이며, 이미 생성된 스레드에 작업을 할당하도록 옵션 체인지도 가능) 원본 함수의 로직을 수행한다.
요점은 스레드를 여러 개 사용할 수 있기 때문에, 오히려 동시성 문제가 발생할 수 있다. 여러 스레드 병렬로 돌리고 싶다고 핵심 로직에 @Async 남발하는 순간 코드는 망가질 수 있다. 공유 자원에 대한 동기화를 기본적으로 제공하지 않는다.
FastAPI - asyncio, async, await
코드 작성하면서 asyncio, async, await 키워드를 가장 많이 사용했으므로 관련 내용을 정리한다.
1) asyncio : Event Loop의 구현체
Event Loop란, 단일 스레드가 I/O 등의 작업을 기다리지 않고 여러 작업을 번갈아 가며 동시에 처리할 수 있게 해주는 루프이다.
일반적으로 read() 함수를 호출해서 외부에서 데이터를 받아와야 한다고 할 때, 스레드는 Blcok된다. 이를 해결하려면 스레드 수를 늘이던가 해야하는데, event loop는 OS가 제공하는 I/O Multiplexing 기술(ex: linux epoll 등)을 활용하여, 하나의 스레드로도 여러 작업들을 번갈아 처리할 수 있도록 한다.
즉 event loop는 개발자 대신 이러한 epoll을 관리해주는 관리자라고 할 수 있겠다.
예를 들어, 낚시할 때 낚싯대 10개를 동시에 올려놓은 후 물고기가 잡히면 알람이 울리도록 한 후에 낚싯대 알람이 울리면 해당 낚싯대만 들어서 물고기를 잡는 그런 방식이다.
스레드가 하나 뿐인데도, 마치 여러 스레드가 도는 것 같은 착각을 주며, 스레드 간 context switching에 드는 비용을 절약해주므로 상당히 가볍고 빠르다.
2) async : Coroutine 함수 선언
일반적인 함수(def)가 아니라, 실행도중에 중단 후 다시 재개할 수 있는 함수임을 선언하는 키워드이다. 일반 함수의 경우 한번 시작 시 return을 만날 때까지 순차 실행되지만 async 함수는 중간에 제어권을 넘길 수 있다.
3) await: 제어권 양보
코드 실행 중, 앞에 await가 붙은 줄을 만나면 해당 줄에서 필요로 하는 작업이 완료되기 전까지 CPU를 낭비하지 않고 다른 작업을 실행하도록 한다.
즉 제어권을 event loop에 넘기는 키워드라고 할 수 있다.
Spring의 경우 멀티스레드 환경에서의 동작을 염두에 두고 다양한 기능들이 존재하지만, FastAPI에서는 주로 단일스레드 환경에서 async, await 등의 키워드를 이용해 이벤트 기반으로 동작하도록 하는 느낌이다.
그러나 Spring에서 소개한 synchronized, reentrant lock은 프로세스가 여러개이며, 각 프로세스마다 여러개의 스레드가 존재할 때에는 안전하지 않다. 태생적으로 단일 프로세스 환경에서 스레드가 여러개일 경우를 염두에 두고 만들어진 것이라, 서비스 배포 시 pod를 여러개 만드는 환경에서는 안전하지 않다.
FastAPI의 asyncio, async, await의 경우 프로세스마다 고유한 event loop가 존재하지만, 멀티 프로세스 환경의 경우 전역 변수들을 공유할 수 없다. 메모리 공간이 독립적이기 때문이다.
단순히 async나 await 같은 키워드를 사용한다고 해서 동시성 문제를 해결해주지 못한다. 내가 이 키워드를 사용한 이유는, I/O 작업(DB read 등)을 주로 하게 되는 서비스에서 context switch 등의 오버헤드 비용을 최소화하여 단일 스레드 환경에서 성능을 더욱 높이기 위함이다. 동시성 문제의 경우 프레임워크에 따른 해결 방법이 다르다기 보다 단순히 표현하는 방식이 다르며 이를 해결하고 성능을 높이는 것은 개발자의 책임인 듯 하다.
sync/async 방식이 뭐가 더 안전하고 뭐가 더 불편하고 이런 느낌의 차이가 아니라, 성능과 복잡성의 trade off이며, 서비스가 규모가 커질수록 일반적으로 더욱 높은 성능을 요구하게 되므로 이러한 작업 방식에 대한 차이와, 기술 스택을 선정한 이유(본 프로젝트에서 애플리케이션 락을 사용하지 않고 lua script를 사용한 이유 등)와도 연결지어 통합적으로 시스템을 설계해야 한다고 느꼈다.