비동기라는 말은 실행이 빠르다는 뜻이 아니라, 작업 요청과 완료를 시간상 분리할 수 있다는 뜻입니다. 따라서 먼저 “누가 어떤 상태를 소유하는가”와 “대기 중 무엇이 점유되는가”를 나눈 뒤, 콜백이 돌아왔을 때 오래된 결과를 적용하지 않는 경계를 설계해야 합니다. 이 구분이 있어야 스레드 수를 늘리는 것과 논리적 직렬성을 보장하는 것을 혼동하지 않습니다.
프레임을 따라가면 submit()은 작업 핸들을 반환하고, get()은 그 핸들의 완료 신호를 호출자에게 동기적으로 기다리게 합니다. 이벤트 루프에서 이 순서가 실행되면 루프가 결과를 생산할 콜백도 처리하지 못할 수 있습니다. 진단할 때는 호출 스택뿐 아니라 future를 완료시키는 executor와 그 executor의 포화 상태를 함께 확인합니다.
Future의 get 호출과 스레드 대기
비동기는 요청과 결과 전달 시점을 나누는 계약입니다. 예를 들어 DB future를 받은 직후 get()을 호출하면 호출 스레드는 완료까지 기다리므로, future가 있다는 사실만으로 논블로킹이 되지 않습니다. 호출 스레드가 이벤트 루프라면 그동안 같은 루프가 맡은 다른 요청도 진행하지 못합니다.
모든 worker가 같은 풀에 부모로 점유된 채 자식 작업의 완료를 동기적으로 기다리면 자식은 큐에 있어도 실행되지 않아 교착할 수 있습니다. await라는 문법도 내부의 동기 I/O를 다른 실행기로 옮겨 주지 않으므로, 실제 대기가 어느 스레드·풀·코루틴 프레임에 남는지 확인해야 합니다.
동기·코루틴·blocking pool·Go 채널의 대기 자원
| 구조 | 대기 중 점유 | 남는 관리 |
|---|---|---|
| 동기 future 대기 | 호출 OS 스레드 | 풀 고갈·교착 |
| 코루틴 중단 | 요청 상태·프레임·버퍼 | 재개 executor·수명·개수 상한 |
| 별도 blocking pool | 그 pool의 worker | 큐·취소·하위 자원 상한 |
| Go 채널 대기 | 보통 park된 goroutine | goroutine 상태·채널·종료 |
Go에서 G는 goroutine, M은 OS 스레드, P는 Go 코드 실행 자원입니다. 채널 대기는 보통 G를 park해 M이 다른 실행 가능한 G를 처리할 수 있게 하지만 syscall·cgo의 블로킹은 다른 경로입니다. GOMAXPROCS를 전체 OS 스레드 개수의 절대 상한으로 보지 않습니다. 런타임 버전과 실제 대기 스택을 확인합니다.
콜백 직렬화와 작업 전체 원자성
계정 잔액이 100이고 A가 이를 읽어 외부 확인을 요청한 뒤 실행기를 내줍니다. B가 잔액을 70으로 바꾼 뒤 A의 콜백이 옛 100 기준으로 90을 저장하면 B의 변경이 사라집니다. 동시에 메모리를 쓰지 않았어도 논리 경쟁은 생겼습니다.
그림을 글로 읽기
- A 시작 · 잔액 100 읽기
- B 실행 · 잔액 70 저장
- A 완료 · 옛 계산 재사용?: 기대 버전·예약 검사 필요
- A 시작 · 잔액 100 읽기 → B 실행 · 잔액 70 저장: A가 비동기 대기
- B 실행 · 잔액 70 저장 → A 완료 · 옛 계산 재사용?: A 결과 도착
작업 전체의 완료까지 다음 계정 작업을 보류하면 단순하지만 느린 앞 작업이 뒤를 막습니다. 대기 중 다른 작업을 허용하려면 예약·기대 버전·결과 세대를 검사하고 충돌·취소·보정을 설계해야 합니다.
Mutex와 계정별 큐의 책임 경계
뮤텍스는 같은 상태를 보호하는 임계 구역에 한 번에 하나만 들어가게 하고, 해제와 이후 획득 사이의 가시성을 제공합니다. 일반적인 mutex는 요청 도착 FIFO를 약속하지 않습니다. 계정별 큐는 실행 순서를 명시하기 쉽지만 내부 동기화·스케줄링·상한 비용이 있습니다.
실행기가 매번 다른 스레드에서 실행해도 안전한 전달과 비중첩을 보장하면 직렬 실행이 가능합니다. 스레드 번호가 같다는 것보다 모든 상태 접근이 소유 경계를 통과하는지가 중요합니다. 내부 상태 참조를 외부에 그대로 넘기면 큐의 보호를 우회할 수 있습니다.
두 계정 이체와 공통 원자 경계
계정 A 큐와 B 큐가 각각 직렬이어도 둘 사이 이체가 하나의 거래가 되는 것은 아닙니다. 서로 상대 큐의 완료를 동기적으로 기다리면 순환 대기가 생깁니다. 같은 DB가 원본이면 두 변경을 한 DB 거래로 묶거나, 안정된 순서의 다중 잠금·공통 조정자를 사용합니다.
transfer(command):
keep_same_logical_request_id()
coordinate_accounts_in_stable_order()
validate_versions_and_balance()
commit_both_changes_in_one_supported_boundary()
record_result()
서로 다른 저장소라 원자 거래를 만들 수 없다면 예약·보상 상태 머신 등 다른 계약을 정해야 합니다. 프로세스를 늘리면 로컬 mutex·큐는 다른 프로세스의 쓰기를 막지 못하므로 최종 권위를 실제 저장 지점에 둡니다.
재현 가능한 점검은 worker 수를 1로 두고 부모 작업이 자식 future를 기다리게 한 뒤, 큐에 자식이 남고 부모가 반환하지 않는지 관찰하는 것입니다. 같은 실험에서 await가 executor를 양보하는 경로와 동기 I/O가 남은 경로를 비교하면, 문법이 아니라 실제 대기 자원이 원인인지 구분할 수 있습니다.
비동기화 뒤 대기 장소와 용량 상한
스레드 대기가 줄어도 요청 객체·인증 문맥·버퍼가 계속 쌓이면 용량 문제는 남습니다. 연결 풀 대기·실제 쿼리·결과 수신·후속 실행을 나누고 active·queue bytes·가장 오래된 요청을 제한합니다. 취소가 실제 외부 실행을 끝냈는지도 확인합니다.
테스트는 같은 풀 자식 대기, A 대기 중 B 변경, 두 계정 역방향 이체, 취소 후 늦은 결과를 포함합니다. 버그가 없다는 근거를 await·actor라는 이름 대신 명시한 상태 전이와 수명 계약에서 찾아야 합니다.