실시간은 “정해진 시간 안에 반드시 끝난다”는 보장이에요. 기준이 평균 속도가 아니라 최악이라는 게 요점이에요. 평균 0.1ms에 도는 루프라도 천 번에 한 번 5ms가 걸리면 1ms 주기 제어에서는 실시간이 아니고, 반대로 매번 0.9ms씩 걸리는 수수한 루프는 실시간이 맞아요. 빠른 코드와 실시간 코드는 다른 물건이에요.
마감을 놓쳤을 때 무슨 일이 나는지로 등급이 갈려요. 한 번의 초과가 곧 시스템 실패인 쪽은 모터 전류 루프예요. 마감을 놓치면 토크가 튀고 최악에는 과전류로 드라이버가 죽어요. 초과가 품질 저하로 끝나는 쪽은 텔레메트리 전송이고요. 한 프레임 늦으면 그래프가 잠깐 끊길 뿐이죠. 로봇 스택은 아래로 갈수록 앞쪽에 가까워요.
핫루프에서 피할 것 셋
마감이 걸린 주기 반복 구간에서 피할 것은 셋으로 좁혀져요. 공통점은 전부 평소엔 빠른데 최악이 무보장이라는 거예요.
첫째는 동적 할당이에요. 평균은 마이크로초 아래지만 최악 지연을 보장하지 않아요. 힙은 프로세스 전역 자료구조라 내부 잠금이 있고, 단편화가 쌓이면 빈 블록을 찾는 탐색이 길어지고, 운영체제에서 새 페이지를 받아오는 순간에는 밀리초 단위가 나올 수 있어요. 소스에 new가 없어도 벡터의 재할당이나 문자열 이어붙이기처럼 숨은 할당이 많다는 게 실전 포인트예요.
둘째는 블로킹 입출력이에요. 이게 가장 자주 밟는 자리라 아래에서 따로 볼게요.
셋째는 무제한 잠금이에요. 잠금 자체가 죄가 아니라 쥐는 시간에 한계가 없는 게 문제예요. 규칙은 C++ 동시성 — 센서 수신과 제어 루프에서 본 그대로예요. 잠금 안에서는 복사와 교환만 하고 계산과 입출력은 밖에서요.
디버그 출력 한 줄이 마감을 깨요
“출력 한 줄 넣었더니 로봇이 가끔 떨려요”가 이 주제의 대표 사고예요.
while (running) {
double cmd = pid_step(target, feedback()); // 계산 — 수 마이크로초
write_motor(cmd); // 논블로킹 레지스터 쓰기
std::cout << "cmd=" << cmd << std::endl; // ← 사고 지점
next += 1ms;
std::this_thread::sleep_until(next);
}입출력은 커널과 장치로 넘어가는 호출이라 언제 돌아올지를 부른 쪽이 정할 수 없어요. 평소에는 사용자 공간 버퍼에 글자만 쌓고 즉시 돌아오기 때문에 멀쩡해 보여요. 그런데 버퍼가 차거나 비우기가 걸리는 바퀴에서는 실제 장치를 기다려요. 터미널이 화면을 다시 그리는 중이면, 디스크 큐가 밀려 있으면, 저장 장치가 내부 정리 중이면 그 한 줄이 수 밀리초에서 수십 밀리초를 먹어요.
std::endl이 매 바퀴 비우기를 강제한다는 점도 걸려요. endl은 줄바꿈과 비우기이고 '\n'은 줄바꿈만이라는 구분이 여기서 값어치를 가져요.
이 지연의 성질이 고약해요. 평균은 거의 그대로이고 최악만 나빠져요. 빠름은 유지되고 실시간만 깨지는 거라, 평균을 보는 측정에는 안 잡히고 몇 시간 돌리다 한 번씩 마감 초과로 나타나요. 재현이 안 되는 떨림의 단골 원인이에요.
그래서 규칙은 이거예요. 핫루프 안에서는 입출력을 부르지 않아요. 핫루프는 고정 용량 버퍼에 숫자만 남기고, 별도의 낮은 우선순위 스레드가 그 버퍼를 비우면서 실제 출력을 해요. 디버깅도 출력을 루프에 심는 대신 카운터와 최댓값을 변수에 누적해 두고 루프가 끝난 뒤 한 번에 내보내요.
메모리는 시작할 때 전부 확보해요
실시간 코드의 메모리 전략은 한 문장이에요. 초기화 때 확보하고 핫루프에서는 재사용만 해요.
벡터를 쓸 거면 시작할 때 용량을 확보하고, 크기가 컴파일 시점에 정해져 있으면 std::array가 더 좋아요. 저장소가 객체 안에 있어서 힙을 아예 안 쓰거든요.
세 번째 도구가 고정 용량 링버퍼예요. 고정 배열 위에서 쓰기 위치가 끝에 닿으면 처음으로 감기는 버퍼로, 할당이 초기화 때 한 번뿐이라 핫루프의 표준 컨테이너예요.
template <std::size_t N>
class LatestN {
std::array<float, N> buf_{}; // 저장소는 멤버 배열 — 힙을 안 쓴다
std::size_t head_ = 0; // 다음에 쓸 자리
std::size_t count_ = 0;
public:
void push(float v) {
buf_[head_] = v; // 가득이면 가장 오래된 값이 덮인다
head_ = (head_ + 1) % N; // 끝에 닿으면 처음으로 감긴다
if (count_ < N) ++count_; // 개수는 N에서 멈춘다 — 할당이 없다
}
float latest() const { return buf_[(head_ + N - 1) % N]; }
};버퍼가 가득 찼을 때의 선택지는 셋뿐이에요. 자리가 날 때까지 기다리거나, 버퍼를 늘리거나, 가장 오래된 값을 덮어쓰거나. 앞의 둘은 핫루프에서 금지된 것들이라 사실상 덮어쓰기만 남는데, 센서 스트림에서는 이게 손해가 아니라 정답이에요.
제어 루프가 원하는 건 지금 세계의 최신 근사예요. 오래된 샘플을 지키느라 새 샘플을 버리면 루프는 점점 과거를 보고 제어하게 되고, 밀린 큐 길이만큼의 지연이 계속 유지돼요. 0.5초 전 거리값으로 장애물을 피하는 로봇을 상상하면 돼요. 통신 미들웨어의 “최근 N개만 유지” 정책이 정확히 같은 사상이에요.
반대로 유실되면 안 되는 데이터, 주행 일지나 블랙박스 로그 같은 것은 마감이 느슨한 영역으로 빼서 블로킹 큐나 파일로 처리해요. 정책은 데이터의 성격이 정해요.
지터는 세 숫자로 재요
주기가 목표에서 흔들리는 정도가 지터예요. 여기서도 같은 원칙이 반복돼요. 평균 주기는 대기 덕분에 거의 항상 그럴싸하게 나오니 착시이고, 마감은 최악이 깨요.
기본 계측은 세 숫자예요. 편차 절댓값의 최대, 편차 절댓값의 평균, 그리고 마감 초과 횟수요. 마감이 엄격한 쪽에서 1번 지표는 초과 횟수예요. “밤새 돌려서 초과 0회”가 통과 조건이 되거든요.
constexpr std::size_t MAX_TICKS = 10000;
std::array<double, MAX_TICKS> period_ms{}; // 계측 저장소도 미리 확보
std::size_t n = 0;
auto prev = std::chrono::steady_clock::now();
while (running && n < MAX_TICKS) {
control_step();
auto now = std::chrono::steady_clock::now();
period_ms[n++] = std::chrono::duration<double, std::milli>(now - prev).count();
prev = now;
}
// 최댓값·평균·초과 횟수 집계와 출력은 루프가 끝난 뒤에계측 저장소가 벡터가 아니라 배열인 것, 출력이 루프 밖인 것이 앞 절 규칙의 실전 적용이에요. 늘어나는 컨테이너에 기록하면 계측 자체가 재할당 지터를 만들고, 루프 안에서 출력하면 계측 자체가 마감을 깨요. 측정 도구가 측정 대상을 오염시키지 않게 하는 것이 계측의 기본이에요.
volatile과 atomic은 다른 문제를 풀어요
둘 다 “다른 데서 바뀌는 변수”에 붙는 것처럼 보여서 헷갈리는데 용도가 갈려요.
volatile은 컴파일러에게 주는 지시예요. 이 변수는 네가 모르는 사이에 바뀔 수 있으니 읽기와 쓰기를 생략하거나 레지스터에 캐싱하지 말라는 뜻이에요. 쓰는 자리는 주소 자체가 하드웨어 레지스터인 경우와, 싱글코어 마이크로컨트롤러에서 인터럽트 핸들러와 메인 루프가 공유하는 변수예요.
volatile이 안 주는 게 중요해요. 원자성이 없고 스레드 사이의 가시성·순서 보장도 없어요. 그래서 멀티코어에서 스레드 공유 변수에 volatile만 붙이면 여전히 데이터 레이스예요.
| 자리 | 도구 |
|---|---|
| 싱글코어의 인터럽트 핸들러 ↔ 메인 루프 | volatile |
| 하드웨어 레지스터 접근 | volatile |
| 스레드 간 플래그·카운터 | std::atomic |
| 스레드 간 복합 상태 | 뮤텍스 + 짧은 잠금 |
응용 코드가 이 규칙을 다 지켜도 제어 스레드를 언제 깨울지는 스케줄러 몫이라 마지막 층은 커널이에요. 실시간 선점 패치, 실시간 스케줄링 정책, 메모리를 램에 고정해 페이지 폴트를 막는 호출, 코어 하나를 제어 전용으로 비우는 격리가 그 층의 도구인데, 실물 배포를 튜닝하는 단계에서 다시 만나게 돼요.
최악을 묶는 일
실시간은 평균을 줄이는 일이 아니라 최악을 묶는 일이에요. 핫루프에서 할당과 입출력과 긴 잠금을 빼고, 메모리를 미리 확보하고, 지터를 계측하면 제어 루프의 마감은 응용 코드 수준에서 지킬 수 있어요.
같은 최악 지연을 메모리 배치 쪽에서 다룬 이야기는 C++ 성능과 메모리 — 숨은 힙 할당이 만드는 최악 지연에 있어요.