파드를 늘렸는데 왜 안 빨라지죠? — 코루틴과 리액터의 다리 3장 Dispatchers.IO.immediate는 왜 없지? — 코루틴 스케줄러 동작 파악하기 4장 내 suspend 컨트롤러는 어느 스레드에서 도나? 5장 인자를 그만 넘기고 싶다 — CoroutineContext / context params 6장 정지 함수와 AOP
cache: CacheService) { @Scheduled(cron = "0 */10 * * * *") fun refresh() { runBlocking { // 스케줄 스레드 점유 if (!cache.isFresh()) { cache.store(cache.load()) } } } } 기본 TaskScheduler 풀 크기 = 1 runBlocking — blocking 세계와 코루틴 세계를 잇는 다리 이름 그대로 block — 안의 코루틴이 끝날 때까지 현재 스레드를 붙잡는다 (단일 스레드 @Scheduled = 틱 밀림)
블로킹 after — Mono를 suspend 다리로 연결 suspend fun sendEvent(id: String) suspend fun sendEvent(id: String) : String = : String = webClient.get().uri("/event/$id") webClient.get().uri("/event/$id") .retrieve() .retrieve() .bodyToMono<String>() .awaitBody() // 논블로킹 대기 .block() // 스레드 점유 → 시그니처가 suspend여도 .block()이면 그 스레드는 묶인다 → 이벤트 루프가 막혀 파드를 늘려도 응답 속도는 그대로 → await 시 스레드 반납 → 다른 요청 처리 → 응답 도착하면 그 지점부터 재개
RequestHeadersSpec<*>.awaitExchange( responseHandler: suspend (ClientResponse) -> T): T { val context = currentCoroutineContext().minusKey(Job.Key) // ① return withContext(context.toReactorContext()) { // ② exchangeToMono { // ③ mono(context) { responseHandler.invoke(it) } }.awaitSingle() // ④ } } ① Job 제거 — mono 빌더가 Job 섞인 컨텍스트를 거부한다 require(context[Job] === null) { "Mono context cannot contain job in it. " "Its lifecycle should be managed via Disposable handle." } ② 코루틴 컨텍스트를 Reactor Context로 전파 ③ suspend 핸들러를 mono{}로 감싸 Reactor 세계로 넘긴다 ④ Mono 결과를 awaitSingle()로 다시 suspend로 되받는다
work-stealing worker-1 worker-2 worker-3 ① 내 로컬 큐 로컬 큐 로컬 큐 ② 글로벌 큐 외부 제출 · overflow ③ 남의 큐 훔치기 ▸ 집는 순서: 로컬 큐(LIFO) → 글로벌 큐 → 남의 큐 훔치기. ▸ IO와 Default는 같은 워커 풀 — CPU 작업(Default)만 코어 수만큼의 실행쓰레드 수(permit) 제한 ▸ 블로킹 진입 시 permit 반납 + 다른 워커 기동 ▸ 각각 워커의 수는 점진적 증가
코루틴 호출 내 로컬 큐에 제출 worker-2 그대로 단일 LIFO 슬롯 우선 스레드 안 바뀜 워커에서 제출? 아니오 · 워커가 아닌 스레드에서 제출 글로벌 큐로 worker-? 로 이동 아무 워커나 집는다 스레드 바뀜 디스패치는 분명히 일어난다 — 목적지가 자기 로컬 큐라서 같은 스레드가 이어받을 뿐이다
4개인 머신이라고 하자 permit = 코어 수만큼 (예: 4개) launch(Dispatchers.Default) { // 열쇠 필요 heavyCalculation() } // → 동시에 4개까지만 실행 Default — 열쇠가 있어야 실행 (3 사용 · 1 여유) launch(Dispatchers.IO) { // 열쇠 없이 jdbcTemplate.query(SQL) } // → blocking 플래그만 붙는다 withContext(Dispatchers.IO) { blockingCall() // 열쇠를 반납한다 } // → 그 슬롯으로 다른 워커가 이어감 IO — 열쇠 없이 실행 최대 max(64, 코어 수)까지 증가 블로킹 진입 시 열쇠 반납 → 대기 워커가 이어받는다
CoroutinesUtils.invokeSuspendingFunction(method, bean, args) // ② Spring MVC 핸들러 (서블릿) spring-web/.../method/support/InvocableHandlerMethod.java → CoroutinesUtils.invokeSuspendingFunction(...) // 실제 스택 at kotlinx.coroutines.reactor.MonoKt$monoInternal$1.accept(Mono.kt:55) at o.s.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.java:110) at o.s.aop.support.AopUtils$KotlinDelegate .invokeSuspendingFunction(AopUtils.java:377) at o.s.aop.framework.CglibAopProxy$CglibMethodInvocation .proceed(CglibAopProxy.java:765)
service(req, …) repo(req, …) 인자 없이 coroutineContext[Key] withContext(RequestContext(req)) handler() util(req, …) service() repo() 요청 스코프 값은 인자가 아니라 컨텍스트로 — 자식 코루틴에 자동 전파
— 이름 붙은 숨은 파라미터로 들어온다 context(ctx: RequestContext) suspend fun process(): Result { val req = ctx.request // 없으면 컴파일 에러 } // 2. 호출부 — context()로 제공한다 val rc = RequestContext(req) context(rc) { process() // rc를 안 넘겨도 꽂아준다 } // 3. 중간 함수도 계속 명시해야 흐른다 (자동 전파 없음) context(ctx: RequestContext) suspend fun step() = process() JVM에선 인자 하나 더 있는 평범한 메서드, 런타임 0비용
= repo.find(id) @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint): Any? { val t0 = System.nanoTime() val result = jp.proceed() // record(System.nanoTime() - t0) return result //
String): Data = repo.find(id) // JVM 실제 시그니처: handler(id, $cont) → jp.args = [id, $cont] // CSP Style @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint): Any? { val t0 = System.nanoTime() val result = jp.proceed(jp.args) // ① record(System.nanoTime() - t0) // ② return result // ③ } ① 원본 Continuation을 그대로 넘긴다. ② 그래서 proceed()는 리턴 값 불명 or 즉시 리턴→ 측정값 ≈ 0ms ③ 리턴값이 Data가 아니라 COROUTINE_SUSPENDED 마커 → 호출부에서 ClassCastException
머신 fun handler(id: String, $cont: Continuation<Data>): Any? { val sm = $cont as? HandlerSM ?: HandlerSM($cont) when (sm.label) { 0 -> { sm.label = 1 val r = repo.find(id, sm) if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED // ← 평범한 JVM return } 1 -> { /* 재개 지점 — sm.result 로 이어붙는다 */ } } } 정지 지점을 즉시 끝낼 수 없으면 continuation을 그 작업에 등록하고 마커를 리턴한다 jp.proceed()는 Method.invoke일 뿐 — 리턴이 오면 AOP는 "끝났다"고 본다 Await가 없으면 정상반환, 있으면 ClassCastException
호출 // AOP 시그니쳐는 mono반환 가능 return mono(ctx) { ... } // Mono → advice 안에서는 suspend 함수를 호출할 수 없다 → 값이 아니라 Publisher(Mono)를 반환하면 코루틴이 대신 기다린다 // advice 시그니처에는 suspend를 붙일 수 없다 fun around(jp: ProceedingJoinPoint): Any? { val rtn = ... rtn.awaitSingle() // 불가 — 여기서 기다릴 수 없다 }
= suspendCoroutineUninterceptedOrReturn<Any?> { cont -> ... } val result = if (rtn is Mono<*>) rtn.awaitSingleOrNull() // Mono가 온 경우 else rtn // 값이 온 경우 ▸ Spring 6.1+: 프록시가 suspend 원본을 Mono로 바꿔 실행 → proceed()는 Mono를 돌려준다 ▸ 그 이전 경로에서는 실제 값(또는 COROUTINE_SUSPENDED 마커)이 온다 ▸ 분기 없이 값으로 캐스팅하면 ClassCastException (spring-framework #31855)
as Continuation<*>).context mono(ctx) { ... } // IllegalArgumentException — Job 포함 mono(ctx.minusKey(Job)) { ... } // OK — Job만 떼고 나머지는 유지 // WebClient의 awaitExchange도 같은 트릭을 쓴다 currentCoroutineContext().minusKey(Job.Key) → MDC·Dispatcher 등 컨텍스트는 살리고 생명주기(Job)만 떼낸다 → 새 코루틴의 Job은 mono 빌더가 직접 만든다
Any? { val args = jp.args val ctx = (args.last() as Continuation<*>).context.minusKey(Job) // ④ return mono(ctx) { // ② val t0 = System.nanoTime() try { val rtn = suspendCoroutineUninterceptedOrReturn<Any?> { cont -> jp.proceed(args.dropLast(1).toTypedArray() + cont) // ③ } if (rtn is Mono<*>) rtn.awaitSingleOrNull() else rtn // ① } finally { record(System.nanoTime() - t0) } } } → ① 마커 처리 · ② Publisher 반환 · ③ Continuation 교체 · ④ Job 제거
정지 지점에서 다시 던진다 mono { throw e } subscriber.onError(e) resumeWithException(e) 코루틴 취소 구독을 취소한다 업스트림까지 전파 job.cancel() invokeOnCancellation subscription.cancel() 반대 방향도 흐른다 — 구독이 dispose되면 mono{} 내부 코루틴이 CancellationException으로 끝난다 minusKey(Job)로 Job 링크를 끊어도 취소는 구독으로 흐른다 — 구조적 동시성 유지
핸들러와 프레임워크 자체 인터셉터(@Transactional 등)까지 사용자 @Around의 suspend 호출은 지원 대상이 아니다 — #31855는 invalid / not planned 로 클로즈 ▸ 동작은 하는데 계약이 없는 회색지대 Job이 빠지니까 여전히 불안정 —결국 AOP를 걷어내고 명시 호출로 돌리는 것 고려