RCU(Read-Copy-Update) 기초 — rcu_read_lock부터 synchronize_rcu까지 실제로 확인하기

라우팅 테이블이나 설정 캐시처럼 읽기는 압도적으로 많고 쓰기는 어쩌다 한 번인 자료구조에 일반 락(mutex/spinlock)을 쓰면, 정작 서로 방해할 일이 없는 리더끼리도 락 때문에 경합한다. RCU(Read-Copy-Update)는 리더 쪽을 아예 락-프리로 만들어버리는 동기화 기법이다. 갱신할 때 원본을 고치는 대신 새 사본을 만들어 포인터만 원자적으로 바꿔치기하고, 옛 버전은 “지금 그걸 읽고 있는 리더가 아무도 없다”고 확신할 수 있을 때만 회수한다. 이 글에서는 RCU의 핵심 아이디어를 정리하고, rcu_read_lock/rcu_dereference/synchronize_rcu를 실제 커널 모듈로 확인한다.

RCU 핵심 아이디어 — Read, Copy, Update

이름 그대로 세 단계다. 리더는 그냥 포인터를 읽는다(Read). 라이터는 기존 객체를 고치지 않고 통째로 복사해 새 값을 채운 사본을 만든다(Copy). 그 사본이 준비되면 원자적 포인터 대입 한 번으로 전체를 새 버전으로 바꿔치기한다(Update). 이 시점부터 새로 들어오는 리더는 전부 새 버전을 보고, 이미 옛 버전을 읽고 있던 리더는 그대로 안전하게 다 읽고 빠져나갈 때까지 옛 메모리가 살아 있다.

여기서 핵심 개념이 grace period다. 모든 CPU가 최소 한 번씩 문맥 전환(quiescent state)을 거치고 나면, 그 이전에 rcu_read_lock()으로 시작된 모든 읽기 구간은 반드시 끝났다고 커널이 보장할 수 있다. synchronize_rcu()는 이 grace period가 끝날 때까지 기다렸다가 반환하는 함수다 — 그 반환 시점 이후로는 옛 버전을 안전하게 kfree()해도 된다.

실제 모듈로 확인하기

정수 하나를 담은 구조체를 RCU로 보호되는 전역 포인터로 관리하면서, 값을 1에서 2로 바꾸는 과정을 그대로 로그로 남기는 모듈이다.

// SPDX-License-Identifier: GPL-2.0

#include <linux/module.h>
#include <linux/rcupdate.h>
#include <linux/slab.h>

struct my_data {
	int value;
};

static struct my_data __rcu *shared_ptr;

static int __init rcu_demo_init(void)
{
	struct my_data *initial, *updated, *old;

	initial = kmalloc(sizeof(*initial), GFP_KERNEL);
	initial->value = 1;
	rcu_assign_pointer(shared_ptr, initial);

	rcu_read_lock();
	pr_info("rcu_demo: reader가 본 값=%d\n", rcu_dereference(shared_ptr)->value);
	rcu_read_unlock();

	updated = kmalloc(sizeof(*updated), GFP_KERNEL);
	updated->value = 2;

	old = rcu_dereference_protected(shared_ptr, true);
	rcu_assign_pointer(shared_ptr, updated);
	pr_info("rcu_demo: 포인터 교체 완료, old(value=%d)는 아직 해제 전\n", old->value);

	synchronize_rcu();
	pr_info("rcu_demo: synchronize_rcu() 반환 — grace period 종료, old를 안전하게 해제 가능\n");

	kfree(old);

	rcu_read_lock();
	pr_info("rcu_demo: reader가 다시 본 값=%d\n", rcu_dereference(shared_ptr)->value);
	rcu_read_unlock();

	return 0;
}

static void __exit rcu_demo_exit(void)
{
	struct my_data *p = rcu_dereference_protected(shared_ptr, true);

	kfree(p);
}

module_init(rcu_demo_init);
module_exit(rcu_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("RCU basic usage demo");

실제로 빌드하고 insmod한 결과다.

$ sudo insmod rcu_demo.ko
$ sudo dmesg | tail -4
rcu_demo: reader가 본 값=1
rcu_demo: 포인터 교체 완료, old(value=1)는 아직 해제 전
rcu_demo: synchronize_rcu() 반환 — grace period 종료, old를 안전하게 해제 가능
rcu_demo: reader가 다시 본 값=2

dmesg 타임스탬프를 실제로 재보면 “포인터 교체 완료” 로그와 “synchronize_rcu() 반환” 로그 사이에 약 82ms 차이가 있었다. synchronize_rcu()가 그냥 즉시 반환하는 게 아니라 실제로 grace period(문맥 전환이 CPU마다 최소 한 번씩 일어나기를)를 기다린다는 걸 시간차로 확인할 수 있다.

rcu_assign_pointer / rcu_dereference가 하는 일

겉보기엔 단순 대입 =과 다를 게 없어 보이지만, 이 두 매크로는 메모리 배리어 역할을 한다. rcu_assign_pointer()는 사본의 필드를 다 채우는 코드가 포인터 대입보다 먼저 완료되도록 쓰기 순서를 강제한다 — 그렇지 않으면 리더가 아직 다 채워지지 않은 반쪽짜리 객체를 볼 수 있다. rcu_dereference()는 그 반대편에서, 포인터를 읽는 것과 그 안의 필드를 읽는 것 사이에 데이터 의존성 배리어를 넣어 CPU가 순서를 재배열하지 못하게 막는다. 그냥 raw 포인터를 그대로 읽고 쓰면 이 보장이 사라진다.

주의사항

  • rcu_read_lock()~rcu_read_unlock() 구간 안에서는 절대 잠들면 안 된다. 이 구간이 끝나야 grace period가 끝날 수 있는데, 그 안에서 스케줄아웃되면 grace period가 무한정 밀릴 수 있다. 잠들어야 하는 읽기 경로가 필요하면 일반 RCU 대신 SRCU(Sleepable RCU)를 쓴다.
  • synchronize_rcu()는 블로킹 함수라 인터럽트 컨텍스트나 락을 쥔 상태에서 호출할 수 없다. 비동기로 회수하고 싶다면 call_rcu()에 콜백을 등록해 grace period가 끝난 뒤 커널이 알아서 콜백을 호출하게 하는 게 정석이다.
  • rcu_dereference_protected(ptr, true)의 두 번째 인자 true는 “동시 라이터가 없다는 걸 내가 보장한다”는 선언이다. 이 데모는 단일 스레드 init/exit 함수 안에서 순서대로 실행되니 true로 충분하지만, 실제로 여러 라이터가 있는 코드에서는 그 보호를 실제로 제공하는 락의 lockdep 조건(예: lockdep_is_held(&my_lock))을 넣어야 한다.
  • 이 데모는 이해를 위해 모든 단계를 한 스레드에서 순서대로 실행했다. RCU의 진짜 이득은 여러 CPU에서 다수의 리더가 락 없이 동시에 rcu_dereference()를 도는 상황에서 나온다 — 단일 스레드 예제만으로는 그 이득 자체를 체감하긴 어렵다.

마무리

RCU는 결국 “읽기는 아무 대가 없이, 쓰기는 비용을 전부 감당한다”는 비대칭적인 트레이드오프다. 라우팅 테이블, 네트워크 네임스페이스, dentry 캐시처럼 커널 곳곳의 읽기 편중 자료구조가 RCU로 보호되는 이유이기도 하다. synchronize_rcu() 호출 전후로 82ms라는 실측 지연이 있었다는 것 자체가, RCU가 “락이 없어 보이지만 사실은 grace period라는 형태로 비용을 뒤로 미룬 것”이라는 걸 잘 보여준다.

참고

답글 남기기