active-mm

SEARCH
Linux Kernel Notes

What is active_mm?

Table of contents

User process A (Task A)의 페이지 테이블에는 Userspace mapping과 Kernelspace mapping이 함께 있다. 한편, Kernel thread K는 Userspace mapping이 필요 없고 Kernelspace mapping만 필요하다.

따라서 A에서 K로 전환할 때, A의 페이지 테이블을 그대로 MMU에 남겨 둬도 K는 문제없이 실행된다.

Linux kernel은 Context switch를 할 때 이 사실을 활용해 다음 스케줄된 스레드가 커널 스레드일 경우 굳이 Address space 전환 (TLB flush, Root page table change) 1 를 하지 않고, 이전에 실행되던 Userspace mapping을 “빌린”다. 여기서 파생된 개념이 active_mm이다.

How to borrow?

주소 공간을 빌리기 위해서 특수한 하드웨어나 알고리즘이 필요한 것은 아니다. 방법은 간단하다. Root Page table을 전환하지 않고 어떤 주소 공간에서 빌려왔는지 기록만 하는 것이다. 이를 위해서 task struct에 기존의 Address space를 가르키는 mm 멤버 변수 외에, active_mm이라는 실제 CPU (core)의 MMU context를 가르키는 멤버 변수를 둔다.

표로 요약하면 다음과 같다.

member 질문 의미
mm 이 태스크의 사용자 주소 공간은 무엇인가? 태스크가 실제로 소유하는 주소 공간
active_mm CPU가 지금 어떤 MMU 컨텍스트를 사용하고 있는가? 현재 활성화됐거나 커널 스레드가 빌린 컨텍스트

Example

예를 들어, CPU 0이 User task A를 실행하고 있다고 가정하면 mmactive_mm은 서로 같은 주소공간 mm_A를 가르킨다.

    A->mm        = mm_A
    A->active_mm = mm_A
    CPU MMU      = mm_A

만약 Task A를 실행하다가 스케줄러가 Kernel thread로 Context switch를 수행한다고 하자. 이 때, CPU 0 위에서 실행될 Kernel thread는 빌려왔던 Userspace의 소유자가 아니므로 mmNULL이다. 대신 CPU (MMU)에 남아 있는 컨텍스트를 active_mm으로 기록한다.

    K->mm        = NULL
    K->active_mm = mm_A       // A에게서 빌린 MMU 컨텍스트
    CPU MMU      = mm_A       // 실제 하드웨어 상태도 그대로

여기서 active_mm == mm_A라는 사실은 K가 A의 Userspace address space를 사용할 권한을 얻었다는 뜻이 아니다. K는 여전히 사용자 주소 공간이 없는 태스크다. 즉, active_mm은 소유권이나 접근 권한이 아니라 현재 MMU 상태와 그 수명을 추적하기 위한 값이며, ’빌려’온 값이다. 이를 그림으로 나타내면 다음과 같다. activemm

Context switch example

예제 케이스를 통해 구체적인 동작을 살펴보도록 하자. 여기서 ‘A’, ’B’는 각각 User process A,B를 나타내고, ’K’는 Kernel thread를 나타낸다.

A → K

User process A에서 Kernel thread K로 Context switch하는 순간을 보자. mm_A 의 페이지 테이블에는 다음 두 종류의 매핑이 이미 들어 있다.

mm_A의 페이지 테이블
├─ User mapping: A의 코드, 힙, 스택, mmap
└─ Kernel mapping: 커널 코드, 커널 데이터, 커널 스택

K가 필요한 것은 아래쪽의 커널 매핑뿐이다. 따라서 사용자 매핑을 바꾸거나 없앨 필요도, 별도 커널 페이지 테이블을 로드할 필요도 없다.

Before switch
    prev = A
    A->mm = A->active_mm = mm_A
    CPU MMU = mm_A

After switch
    next = K
    K->mm = NULL
    K->active_mm = mm_A
    CPU MMU = mm_A            // No TLB flush

스케줄러의 의도를 의사 코드로 쓰면 다음과 같다.

/* next가 커널 스레드일 때: user -> kernel */
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
mmgrab_lazy_tlb(prev->active_mm);   /* 빌린 컨텍스트의 수명 보장 */

enter_lazy_tlb()는 아키텍처에 이번에는 실제 주소 공간 전환이 없다는 사실을 알리는 함수이다. 위에서도 계속 말하지만, 기억해야 할 점은 switch_mm*()로 루트 페이지 테이블을 바꾸지 않는다는 점이다. K는 mm_A의 사용자 매핑을 쓰지 않고, 그 안에 이미 있는 커널 매핑만 사용한다.

K → B

다음에 사용자 태스크 B를 실행해야 할 때는 이야기가 달라진다. mm_A에 들어 있는 사용자 매핑은 A의 것이고, B는 자기 사용자 주소 공간 mm_B를 실제로 필요로 한다. 따라서 이때 비로소 MMU 컨텍스트를 교체한다. (Root page table을 바꿈)

K → B

전환 전: K->mm = NULL, K->active_mm = mm_A, CPU MMU = mm_A
전환 후: B->mm = B->active_mm = mm_B, CPU MMU = mm_B

개념적으로는 다음 순서다.

/* next가 사용자 태스크일 때: kernel -> user */
membarrier_switch_mm(rq, prev->active_mm, next->mm);
switch_mm_irqs_off(prev->active_mm, next->mm, next);

/* prev가 커널 스레드였다면, 빌렸던 mm_A의 lazy 참조는
 * finish_task_switch()에서 mmdrop_lazy_tlb()로 반환한다.
 */

switch_mm_irqs_off()가 페이지 테이블 루트와 아키텍처별 ASID/PCID 상태를 실제로 바꾸는 지점이다. 따라서 lazy MM switching은 A → K에서 불필요한 전환 하나를 생략하고, K → B에서 필요한 전환만 수행하는 최적화다.

active_mm lifetime

이제 lifetime 관점에서의 이 방법의 안전성에 대해 살펴보자.

CPU 0에서 커널 스레드 K가 mm_A를 lazy 컨텍스트로 쓰는 동안, CPU 1에서 주소 공간을 빌려왔던 User process A가 종료할 수 있다. A의 Userspace mapping이 더는 필요 없다고 해서 CPU 0이 아직 가리키는 mm_struct까지 즉시 없애면 안 된다.

K가 다른 컨텍스트로 전환하기 전까지 CPU 0의 MMU는 여전히 mm_A를 가리킨다.

이 때문에 mm_struct에는 서로 다른 수명을 나타내는 두 카운터가 있다.

카운터 보호하는 대상 대표적인 획득/반환
mm_users 실제 사용자 주소 공간의 사용자 mmget() / mmput()
mm_count mm_struct 객체 자체의 장기 수명 mmgrab() / mmdrop()

mm_users의 마지막 참조가 사라지면 mmput() 경로가 사용자 매핑과 사용자 공간을 정리한다. 반면 mm_count가 0이 되기 전까지 mm_struct 자체와 lazy MM 컨텍스트에 필요한 기반 객체는 남아 있다.

중요한 구분이 하나 있다. mmgrab()mm_struct가 해제되지 않도록 붙잡을 뿐, 사용자 주소 공간의 매핑이 그대로 남는다고 보장하지는 않는다. 따라서 lazy active_mm은 “나중에 A의 사용자 포인터를 읽어도 된다”는 권한이 아니다. 그것은 단지 이미 활성화된 MMU 컨텍스트를 안전하게 떠안기 위한 참조다.

개념적으로 mm_count에는 다음이 반영된다.

실제 사용자(mm_users > 0)가 있으면 기본 참조 1개
+ lazy 커널 스레드가 들고 있는 참조
+ 다른 mmgrab() 사용자들의 장기 참조

그래서 A가 종료해 mm_users == 0이 되어도 K의 lazy 참조가 남아 있으면 mm_count는 0이 되지 않는다. K가 나중에 사용자 태스크로 전환하며 그 참조를 반환한 뒤에야 객체를 완전히 해제할 수 있다.

mmgrab_lazy_tlb() helper

lazy 참조는 항상 mmgrab()과 같은 방식으로 구현되는 것이 아니다. CONFIG_MMU_LAZY_TLB_REFCOUNT=y인 구성에서는 mmgrab_lazy_tlb() / mmdrop_lazy_tlb()가 각각 mmgrab() / mmdrop()처럼 동작한다.

반대로 일부 구성에서는 lazy 사용자를 mm_count로 세지 않고, 주소 공간을 정리할 때 해당 CPU의 lazy 상태를 shootdown으로 비운다. 이 경우에도 mmdrop_lazy_tlb()membarrier가 요구하는 메모리 배리어를 제공해야 한다.

따라서 lazy MM 경로에서는 직접 mmgrab()이나 mmdrop()을 쓰지 말고 항상 아래 헬퍼를 사용해야 한다.

mmgrab_lazy_tlb(mm);
/* ... */
mmdrop_lazy_tlb(mm);

이 헬퍼가 “이 커널 구성은 참조 카운트 방식인가, shootdown 방식인가”라는 차이를 감춘다. 앞 절의 mm_count 설명은 참조 카운트 방식에서 가장 직관적으로 보인다. shootdown 방식에서는 lazy 사용자가 mm_count에 들어가지 않을 수 있다.

예외

kthread_use_mm()

보통 일반 커널 스레드는 사용자 주소 공간을 전혀 필요로 하지 않는다. 그러나 io_uring 워커, vhost, OOM reaper처럼 특정 사용자 주소 공간을 의도적으로 다뤄야 하는 커널 스레드도 있다. 이때 kthread_use_mm(mm)을 사용한다.

이 함수는 잠시 커널 스레드의 tsk->mm을 설정하고 switch_mm_irqs_off()로 해당 mm을 실제 활성화한다. 즉 “사용자 매핑이 필요 없으니 기존 컨텍스트를 빌린다”는 lazy MM의 전제를 의도적으로 깨는 경로다.

/* 개념적 흐름 */
mmgrab(mm);                 /* mm_struct의 수명 확보 */
tsk->active_mm = mm;
tsk->mm = mm;
switch_mm_irqs_off(old_active_mm, mm, tsk);
mmdrop_lazy_tlb(old_active_mm);

kthread_unuse_mm()은 반대 방향으로 돌아온다. 이 함수가 tsk->mm = NULL로 만든 뒤에도 CPU의 active_mm은 잠시 방금 사용한 mm을 가리킨다. 따라서 먼저 lazy 참조를 확보한 뒤, use_mm()에서 잡았던 참조를 반환해야 한다.

tsk->mm = NULL;
mmgrab_lazy_tlb(mm);        /* 아직 active_mm인 mm의 수명 확보 */
enter_lazy_tlb(mm, tsk);
mmdrop(mm);                 /* kthread_use_mm()의 mmgrab() 상쇄 */

이 순서가 없으면 다른 CPU에서 마지막 실제 사용자가 사라진 순간, 아직 MMU에 걸려 있는 mm이 조기에 해제될 수 있다. 또한 kthread_use_mm()mmgrab()만으로 사용자 매핑의 존속까지 보장되지는 않는다. 호출자는 필요한 사용자 주소 공간 수명 규칙을 별도로 만족시켜야 한다.

성능과 주의사항

얻는 것

커널 스레드로 갈 때 실제 페이지 테이블 전환과 그에 따르는 아키텍처별 TLB·ASID/PCID 관리 작업을 피한다. 이후 커널 스레드가 계속 실행되거나 다른 커널 스레드로 바뀌어도 같은 컨텍스트를 이어 쓸 수 있다.

지켜야 할 것

  1. active_mm을 빌렸다고 사용자 주소 공간을 쓸 수 있는 것은 아니다. 사용자 메모리가 필요한 커널 스레드는 kthread_use_mm() 같은 명시적 경로를 써야 한다.
  2. lazy 참조는 반드시 mmgrab_lazy_tlb() / mmdrop_lazy_tlb()로 관리한다. 직접적인 mm_count 조작은 구성별 차이를 깨뜨린다.
  3. membarrier 때문에 MM 전환과 lazy 참조 반환의 순서는 단순한 정리 문제가 아니다. context_switch()finish_task_switch()의 배리어 순서를 임의로 바꾸면 안 된다.

요약

커널 스레드는 사용자 주소 공간이 필요 없다.
→ 하지만 CPU에는 유효한 MMU 컨텍스트가 필요하다.
→ 직전 사용자 태스크의 컨텍스트는 커널 실행에 충분하다.
→ 그러므로 user -> kernel에서는 그것을 active_mm으로 빌리고 실제 MM 전환을 생략한다.
→ 다음 user 태스크가 필요해질 때만 실제 MM 전환을 한다.
→ 빌린 컨텍스트가 너무 일찍 사라지지 않도록 lazy 참조 또는 shootdown 규칙으로 수명을 관리한다.

mm은 “누구의 사용자 주소 공간인가”를, active_mm은 “CPU가 지금 어떤 MMU 컨텍스트를 쓰는가”를 나타낸다. Lazy MM switching은 이 둘을 분리했기 때문에 가능한, 성능 최적화이면서 동시에 수명 관리 규칙이다.

출처

  1. Linux Kernel Documentation, Active MMmm/active_mm의 역할, lazy 사용자와 두 참조 카운터의 관계.
  2. Linux mainline, include/linux/sched/mm.hmmgrab(), mmdrop(), mmgrab_lazy_tlb(), mmdrop_lazy_tlb()의 수명·배리어 계약.
  3. Linux mainline, kernel/sched/core.ccontext_switch()의 user/kernel 전환 경로.
  4. Nicholas Piggin, lazy tlb: introduce lazy tlb mm refcount helper functions — lazy 참조 전용 헬퍼가 도입된 이유와 kthread_unuse_mm() 경로.

Footnotes

  1. x86_64의 경우 CR3 레지스터의 내용을 바꾸는 것

Discussion

Comments