lwIP를 RTOS 위에서 쓰다 보면 송신 재시도 루프가 은근히 중요해진다.
송신 버퍼가 잠시 부족하거나,
상대 ACK 처리가 아직 반영되지 않았거나,
TCP/IP stack 내부 작업이 밀려 있으면 “지금은 못 보내지만 조금 뒤에는 가능”한 상태가 된다.
이때 아무 대기 없이 계속 재시도하면 문제를 풀기보다 더 꼬이게 만들 수 있다.
오늘 메모는 TCP 재시도 루프에서 RTOS delay가 왜 필요한가다.
Delay는 그냥 쉬는 코드가 아니다
RTOS에서 delay를 처음 보면 단순히 “몇 ms 쉬는 기능”처럼 느껴진다.
하지만 실제 의미는 조금 다르다.
Task delay는 CPU를 멈추는 기능이 아니라,
현재 실행 중인 task를 잠시 대기 상태로 보내고 다른 task가 실행될 기회를 주는 기능이다.
특히 TCP 송신 재시도나 장치 I/O 대기처럼
“지금은 안 되지만 잠시 뒤 다시 시도하면 되는” 상황에서 중요하다.
이때 아무 대기 없이 계속 반복하면 CPU를 붙잡는 busy loop가 되기 쉽다.
Delay는 CPU 전체를 멈추지 않는다
RTOS 환경에서 delay는 시스템 전체를 정지시키는 동작이 아니다.
현재 실행 중인 task만 실행 대상에서 잠시 빠지고,
scheduler는 그동안 ready 상태의 다른 task를 실행한다.
흐름으로 보면 대략 이렇게 이해할 수 있다.
- 현재 task가 실행 중인 상태에서 delay를 요청한다
- 해당 task는 일정 시간 동안 blocked 상태가 된다
- 그동안 scheduler는 다른 task를 실행한다
- 시간이 지나면 task는 다시 ready 상태가 된다
- 우선순위와 scheduler 상태에 따라 다시 실행된다
여기서 중요한 점은
대기 시간이 끝났다고 해서 즉시 실행된다는 뜻은 아니라는 것이다.
대기 시간이 끝나면 “실행 가능 상태”가 될 뿐이다.
실제 실행 시점은 다른 task의 우선순위, 현재 실행 중인 작업, scheduler 설정에 따라 달라진다.
Tick 단위를 먼저 이해해야 한다
RTOS는 보통 tick interrupt를 기준으로 시간을 관리한다.
tick은 RTOS가 시간 흐름을 계산하는 기본 단위다.
예를 들어 1초에 tick이 1000번 발생하도록 설정되어 있다면
1 tick은 약 1ms가 된다.
반대로 1초에 tick이 100번 발생하도록 설정되어 있다면
1 tick은 약 10ms가 된다.
그래서 delay에 넣는 값이 항상 밀리초를 의미한다고 생각하면 위험하다.
같은 숫자라도 tick 설정에 따라 실제 대기 시간이 달라질 수 있다.
실무에서는 보통 밀리초 단위의 의도를 tick 단위로 변환해서 사용한다.
그래야 tick 설정이 바뀌어도 코드가 의도한 시간에 가깝게 동작한다.
Busy loop가 문제가 되는 이유
lwIP TCP 송신 중에는 일시적으로 바로 전송할 수 없는 상황이 생길 수 있다.
송신 버퍼가 잠시 부족하거나,
이전 패킷 처리와 ACK 처리가 아직 끝나지 않았거나,
네트워크 stack 내부 작업이 밀려 있을 수 있다.
이때 아무 대기 없이 곧바로 재시도하면 이런 흐름이 된다.
- 송신 시도
- 아직 보낼 수 없음
- 즉시 다시 송신 시도
- 다시 실패
- 계속 반복
겉으로는 “빠르게 재시도하는 코드”처럼 보이지만,
실제로는 CPU를 계속 소비하는 busy loop가 된다.
busy loop는 다음 문제를 만든다.
- CPU 사용률이 불필요하게 올라간다
- 같은 우선순위 또는 낮은 우선순위 task의 실행 기회가 줄어든다
- 네트워크 stack을 처리하는 thread가 늦게 실행될 수 있다
- ACK 처리나 송신 버퍼 해제가 지연될 수 있다
- 결과적으로 재시도가 더 오래 실패할 수 있다
즉, 빠르게 재시도하려고 만든 코드가
오히려 네트워크 stack이 회복할 시간을 빼앗을 수 있다.
짧은 delay는 회피가 아니라 양보다
재시도 루프에 짧은 delay를 넣는 목적은
정확히 일정한 간격으로 다시 실행하기 위해서가 아니다.
핵심은 CPU를 잠시 양보하는 것이다.
현재 task가 잠깐 blocked 상태가 되면,
그 사이에 다른 task나 네트워크 처리 thread가 실행될 수 있다.
그러면 송신 버퍼가 비워지거나,
ACK 처리가 진행되거나,
내부 이벤트가 처리될 기회가 생긴다.
이런 관점에서 짧은 delay는 단순한 “대기”가 아니라
시스템 전체 흐름을 풀어 주는 작은 양보에 가깝다.
특히 RTOS 기반 네트워크 코드에서는
“계속 시도하는 것”보다
“잠깐 양보하고 다시 시도하는 것”이 더 안정적인 경우가 많다.
Timeout도 함께 있어야 한다
재시도 루프에 delay만 넣으면 busy loop는 피할 수 있다.
하지만 그것만으로는 충분하지 않을 수 있다.
일시적인 실패가 아니라 실제로 더 이상 진행될 수 없는 상황이라면,
task가 같은 루프에 오래 갇힐 수 있기 때문이다.
그래서 재시도 로직에는 timeout을 함께 두는 편이 좋다.
흐름은 단순하다.
- 일시적으로 진행할 수 없는 상태인지 확인한다
- 아직 timeout을 넘지 않았는지 확인한다
- 짧게 delay를 주고 CPU를 양보한다
- 누적 대기 시간을 갱신한다
- timeout을 넘으면 실패로 빠져나온다
이 구조를 두면 두 가지를 동시에 피할 수 있다.
- 아무 대기 없이 도는 busy loop
- 끝없이 기다리는 무한 재시도
네트워크 코드에서는 이 둘을 모두 피하는 것이 중요하다.
초기화 코드나 interrupt에서는 조심해야 한다
Task delay는 기본적으로 scheduler가 동작 중인 task context에서 사용하는 기능이다.
아직 scheduler가 시작되지 않은 초기화 단계에서 사용하거나,
interrupt context에서 직접 사용하는 방식은 적절하지 않다.
interrupt에서는 일반 task용 delay를 걸 수 없다.
필요하다면 interrupt 전용 이벤트 전달 방식으로 task를 깨우거나,
queue, semaphore, event flag 같은 동기화 수단을 interrupt용 API로 사용해야 한다.
이 기준을 어기면 단순한 delay 문제가 아니라
스케줄링 정지, assert, 예측하기 어려운 타이밍 문제로 이어질 수 있다.
주기 작업과 재시도 작업은 다르다
반복 주기 task와 재시도 루프는 목적이 다르다.
주기 task는 “정해진 기준 시점마다 실행”되는 것이 중요하다.
예를 들어 10ms마다 센서를 읽어야 하는 작업이라면,
작업 수행 시간까지 포함해서 전체 주기를 일정하게 유지해야 한다.
반면 네트워크 송신 재시도는 정확히 10ms마다 실행되는 것이 핵심이 아니다.
중요한 것은 일시적인 실패 상황에서 CPU를 계속 붙잡지 않고,
다른 task와 네트워크 stack이 처리될 시간을 주는 것이다.
그래서 재시도 루프에서는 짧은 일반 delay가 잘 맞는다.
정확한 주기 제어보다 CPU 양보와 timeout 관리가 더 중요하기 때문이다.
실무에서 볼 만한 체크리스트
네트워크 송신 재시도나 장치 I/O 대기 루프를 볼 때는 아래를 먼저 확인한다.
- 일시 실패 상태에서 즉시 반복하지 않는가
- 재시도 사이에 CPU를 양보하는 짧은 delay가 있는가
- delay 값이 tick 설정에 의존해 엉뚱한 시간이 되지 않는가
- timeout 없이 무한히 반복하지 않는가
- scheduler 시작 전이나 interrupt context에서 delay를 호출하지 않는가
- 정확한 주기 작업과 단순 재시도 대기를 같은 방식으로 처리하지 않는가
이 체크만 해도 많은 “가끔 멈춤”, “CPU가 높음”, “송신이 더 안 됨” 같은 문제를 줄일 수 있다.
한 줄 요약
RTOS에서 task delay는 단순히 시간을 버리는 기능이 아니라,
현재 task를 잠시 대기시켜 다른 task와 네트워크 stack이 실행될 기회를 주는 스케줄링 도구다.
네트워크 송신 재시도처럼 일시적으로 진행이 막히는 루프에서는
짧은 delay와 timeout을 함께 두어 busy loop와 무한 대기를 모두 피하는 편이 안전하다.
추천 키워드
FreeRTOS, RTOS, task delay, busy loop, TCP retry, embedded scheduling
DevBJ | 오늘을살자, Log Today