리눅스 cgroup 핵심과 프로세스/스레드 및 cgroup 사이의 연결을 가능하게 하는 자료구조와 락킹을 코드 관점에서 살펴본다.
자동차의 보닛을 열어 내부를 들여다보면, 저는 주요 부품이 무엇인지 간신히 구분할 수 있을 뿐이고, 엔진오일을 직접 갈아볼 생각은 더더욱 하지 못합니다. 정교한 소프트웨어의 내부를 들여다보는 일은 전혀 다른 이야기입니다. 가장 작은 요소들조차 함께 뛰어다니고 춤을 추며, Disney의 Fantasia 속 장면처럼 보이기까지 합니다. 그러니 리눅스 control groups만큼 풍성한 대상을 둘러보는 여정이 코드 안을 탐험하며 어떤 패턴을 찾을 수 있는지 살펴보지 않고 완성될 리는 없습니다.
$ sudo subscribe today 오늘 구독하고 LWN 권한을 한 단계 끌어올리세요. 발행되는 즉시 LWN의 고품질 기사 전체에 접근할 수 있으며, 그 과정에서 LWN을 지원하는 데도 도움이 됩니다. 지금 행동하세요. 무료 체험 구독으로 시작할 수 있습니다.
우리는 이미 몇몇 cgroup 서브시스템을 조금씩 엿본 적이 있으니, 이번 탐사는 cgroups 코어, 특히 프로세스와 cgroup 사이의 상호 연결에 초점을 맞출 것입니다. 여기서 계속 염두에 두고 싶은 질문 하나는 서로 다른 접근법의 상대적 비용입니다. 지난번에는 단일 계층을 사용하면서도 여러 자원에 대해 독립적인 분류를 허용하려 하면 그룹 수가 조합 폭발로 이어질 수 있다는 우려를 보았습니다. 로그인 세션 같은 Q개의 관리 그룹이 있고, 각 그룹에서 N개의 서로 다른 자원 각각에 대해 프로세스를 M가지 방식으로 분류하고 싶을 수 있다면, Q x M x N개의 서로 다른 그룹이 필요할지도 모릅니다. 자원마다 별도의 계층을 허용한다면 Q+M x N개의 그룹만 있으면 됩니다. 그렇다면 질문은 이것입니다. 단일 계층의 단순함이 더 많은 수의 cgroup이라는 비용을 상쇄할까요?
계층 자체를 관리하는 일은 아마 그다지 흥미롭지 않을 것입니다. 기본적인 트리 자료구조는 대부분의 컴퓨터 과학 강의에서 다루고, cgroup 계층의 모양도 그런 구조와 크게 다르지 않을 가능성이 큽니다. 흥미로운 질문은 프로세스가 cgroup과 어떻게 연결되는가입니다. 다양한 cgroup 서브시스템을 살펴보면서 확인했듯이, 때로는 특정 서브시스템과 관련된 cgroup으로 프로세스에서 매핑해야 하고, 때로는 어떤 cgroup에 속한 모든 프로세스 목록을 얻어야 합니다. 이런 매핑을 가능하게 하는 자료구조가 우리의 주요 초점이 될 것입니다.
다만, 먼저 명확히 할 점이 있습니다. cgroup은 우리가 반복해서 그렇게 묘사해 왔음에도 실제로는 프로세스의 그룹이 아닙니다. 사실은 스레드의 그룹입니다. 그래서 먼저 스레드와 프로세스가 서로 및 관련 객체들과 어떻게 연결되는지 살펴보며 그 차이를 분명히 하겠습니다.
V6 Unix에서 초기 BSD들, 그리고 리눅스의 여러 해 동안, 프로세스는 중심적이고 잘 정의된 객체였습니다. 프로세스는 단일 실행 스레드, 메모리를 위한 주소 공간, 프로세스 ID 번호, 시그널 핸들러 집합 등 여러 세부 사항을 가졌습니다. 프로세스와 비슷한 다른 것은 없었고, 혼동할 여지도 없었습니다.
V6 Unix(1975년 출시)에서는 이 프로세스들이 PID로 인덱싱되는 미리 할당된 struct proc 배열로 표현되었습니다. 제어 tty 같은 다른 키로 프로세스를 찾을 필요가 있으면, 코드는 단순히 배열 전체를 검색했습니다. 분명 더 단순하고 덜 복잡했던 시대였습니다. 4.3BSD(1986)에 이르러 이 고정 배열은 더 동적인 연결 리스트가 되었고, 4.4BSD에서는 프로세스 테이블 전체를 검색하지 않고도 프로세스 그룹 내 프로세스를 찾을 수 있도록 보조 리스트까지 생겼습니다. 그 이후로는 복잡해지기만 했지만, 여전히 단순한 우아함이 들어갈 여지는 남아 있습니다.
리눅스에서는 4.4BSD에서 처음 보았던 3단계 계층(세션, 프로세스 그룹, 프로세스)에 프로세스 아래 한 단계가 추가되었습니다. 바로 스레드입니다. 그 결과는 오른쪽에 그려진 그림과 비슷한 계층이 됩니다. 스레드는 자신의 실행 컨텍스트와 자신의 "스레드 ID" 번호를 가지지만, 같은 프로세스 내 다른 스레드들과 대부분의 다른 세부 사항, 특히 주소 공간, 시그널 핸들러 집합, 그리고 프로세스 ID 번호를 공유할 수 있습니다.
내부적으로 스레드는 struct task_struct로 표현되며 때때로 task라고 불립니다. 불행히도 프로세스도 때때로 task라고 불립니다. 예를 들어 do_each_pid_thread() 매크로는 (충분히 그럴듯하게) 스레드를 순회합니다. 프로세스를 순회하는 대응 매크로는 do_each_pid_task()입니다(두 매크로 모두 pid.h에 정의되어 있습니다). "process"라는 용어가 다소 더 믿을 만하긴 하지만, PID(또는 "process ID")라는 용어가 스레드, 프로세스, 프로세스 그룹, 세션에 모두 쓰이기도 하므로, 때로는 더 정확한 "thread group"라는 용어를 고수하는 편이 안전합니다.
세션, 프로세스 그룹, 프로세스, 스레드라는 이 네 객체가 모두 균일한 방식으로 관리된다면 특히 우아할 것입니다. 거의 그렇긴 하지만, 스레드는 여전히 약간 특별 취급을 받습니다. 모든 차이를 정당화하긴 어렵더라도, 스레드를 진짜로 다르게 만드는 두 속성이 있습니다. 첫째, 프로세스 내부에는 언제나 "group_leader"로 알려진 구별되는 스레드가 하나 있습니다. 이는 "이것이 프로세스 다"라고 가리킬 수 있는 명확한 기준점을 제공합니다. group_leader의 스레드 ID는 프로세스 전체의 프로세스 ID입니다. 세션에도 (약한 의미로) leader 프로세스가 있긴 하지만, 이 프로세스가 세션의 나머지보다 먼저 종료될 수 있습니다. 프로세스 그룹에는 어떤 종류의 리더도 없습니다. 둘째, 스레드는 종료(exit)로만 프로세스를 떠날 수 있습니다. 프로세스 그룹에서는 프로세스가 한 프로세스 그룹에서 다른 프로세스 그룹으로 옮길 수 있는 것과 달리, 스레드가 다른 프로세스로 이동하는 것은 불가능합니다. 이는 락킹에 중요한 함의를 갖습니다.
리눅스에 "PID namespaces" 개념이 도입되어 같은 프로세스가 서로 다른 네임스페이스에서 다른 PID를 가질 수 있게 되면서, 첫 세 종류의 객체를 함께 연결하는 "struct pid"(파란색으로 표시됨)가 생겼습니다. 이 논의에서 struct pid의 중요한 멤버는 다음입니다.
struct hlist_head tasks[PIDTYPE_MAX];
여기서 PIDTYPE_MAX는 다음에서 정의됩니다.
enum pid_type { PIDTYPE_PID, PIDTYPE_PGID, PIDTYPE_SID, PIDTYPE_MAX };
(따라서 MAXimum이 아니라 PIDTYPE들의 개수임이 분명합니다) 그리고 struct task_struct에는 이에 대응하는 필드(노란색으로 표시됨)가 있습니다.
struct pid_link
{
struct hlist_node node;
struct pid *pid;
} pids[PIDTYPE_MAX];
(이 코드는 지면을 위해 재포맷하는 과정에서 약간의 재량이 들어갔음을 참고하세요).
각 PID에는 task_struct의 세 개 리스트(hlist_head)가 있으며, 각 task_struct 안의 세 개 hlist_node를 통해 서로 연결됩니다. 세션과 프로세스 그룹 리스트(PIDTYPE_SID와 PIDTYPE_PGID)는 실제로는 프로세스의 리스트이며, 이 리스트에는 스레드 그룹 리더만 나타납니다.
PIDTYPE_PID 리스트는 기대할 법하게 스레드 그룹의 모든 스레드 목록이 아니라, 이 PID를 스레드 ID로 갖는 스레드(아직 존재한다면) 하나만의 리스트입니다. 단일 항목만 담기 위해 리스트를 사용하는 것은 이상해 보이지만 이유가 있습니다. 프로세스의 한 스레드가(어떤 형태든) exec() 시스템 콜을 호출하면, 같은 프로세스의 다른 모든 스레드는 종료(SIGKILL)되고, exec()를 수행한 스레드가 스레드 그룹 리더의 정체성(특히 PID)을 승계합니다. 그 결과 짧은 시간 동안 두 개의 서로 다른 스레드가 같은 스레드 ID를 가질 수 있습니다. PIDTYPE_PID에 리스트를 두면 이것이 가능해집니다.
프로세스 안의 스레드 목록은 다음 필드를 사용해 전혀 별도로 관리됩니다.
struct list_head thread_group;
앞의 세 리스트는 struct pid에 있는 뚜렷한 "head"를 가졌고, 각 struct task_struct에 "node"가 하나씩 있었습니다. 이 스레드 리스트는 head나 tail이 없는 루프(빨간색으로 그림)를 이룹니다. 이는 미묘하지만 중요한 이유 때문에 문제가 될 수 있는데, 놀랍게도 다른 세 리스트에서는 문제가 되지 않을 수도 있는 이유입니다.
스레드는 파괴되는 동안에만 스레드 그룹(즉, 프로세스)에서 제거되므로, 강한 락 없이도 리스트를 순회하는 것이 안전합니다. 가벼운 "RCU read lock"만으로 충분합니다. 스레드가 리스트에서 제거될 때도, 리스트를 순회 중이며 삭제되는 스레드를 현재 바라보고 있던 코드가 끝까지(루프 리스트이므로 사실상 시작 지점으로 되돌아오는 지점까지) 계속 진행할 수 있을 만큼 충분히 잠시 동안은 "반쯤" 남아 있습니다.
이 그림의 잠재적 문제는 어떤 코드가 스레드 리스트를 순회하면서 시작점으로 삼은 스레드가 순회가 끝나기 전에 종료해 버리면, 시작점을 다시 찾지 못해 영원히 루프를 돌 수 있다는 점입니다. 스레드 그룹 리더에서 시작하는 것은 언제나 안전합니다(다른 모든 스레드가 종료할 때까지 "좀비"로 남기 때문입니다). 하지만 API에는 시작점을 강제하는 장치가 없습니다. 이런 의심스러운 사용의 예로 fill_stats_for_tgid()가 있는데, 이는 리스트의 모든 스레드에 걸쳐 통계를 누적합니다. 만약 스레드 그룹 리더가 아닌 PID에 대해 이것이 요청된다면(이상하긴 하지만 충분히 가능함), 스레드가 나쁜 타이밍에 종료하면 문제가 생길 수 있습니다. Oleg Nesterov(Linux 3.14 개발 중에 작성)에 따르면 "거의 모든 lockless 사용[이 스레드 링크를 쓰는]은 잘못"이라고 합니다.
따라서 Linux 3.14에서는(그림에는 없음) 뚜렷한 thread_head를 갖는 새로운 연결 구조가 도입되었습니다. thread_head는 스레드가 아니라 프로세스에 특화된 여러 필드를 담는 signal_struct에 저장됩니다. 이 head가 다른 리스트 헤드처럼 struct pid에 나타나지 않는다는 점은 조금 아쉽지만, 실용적 이점은 별로 없을 것입니다. 시간이 지나면 모든 사용자는 thread_group 연결에서 벗어나야 하며, 그때가 되면 이것은 폐기될 수 있습니다.
이 문제는 락킹이 미묘하면서도 중요하다는 점을 상기시켜 주므로, 반드시 제대로 이해해야 합니다. 여기서 살펴본 모든 리스트뿐 아니라, 프로세스의 자식들을 서로 연결하는 children/sibling 리스트, 모든 프로세스를 서로 연결하는 init_task/tasks 리스트에도, tasklist_lock이라는 도움 되는 이름의 reader/writer 스핀락이 있어 모든 접근과 변경을 보호합니다. 그 락 없이 허용되는 리스트 접근은 프로세스 내 스레드를 따라가는 것(가능하면 새 thread_head 리스트를 사용)과 init_task에서 시작해 모든 task group leader를 순회하는 것뿐입니다. 스레드는 이 리스트들에서 다른 곳으로 이동되지 않으므로, RCU read lock만으로도 안전합니다.
이 tasklist_lock에 문제가 있다는 지적도 있습니다. 문제 중 하나는 여러 겹치는 reader가 write 접근이 필요한 프로세스를 굶길 수 있다는 점입니다. 다시 Oleg Nesterov(이 패치셋에 대한 답글에서)는 "모두가 tasklist[_lock]은 죽어야 한다는 데 동의하는 듯"하다고 말합니다.
프로세스와 스레드의 모든 리스트를 RCU-safe하게 만드는 것은 흥미로운 연습이 될 것입니다. 이는 결코 사소하지 않을 것이고, Thomas Gleixner가 4년보다 훨씬 전에 제안했음에도 아직 일어나지 않았습니다. 하지만 VFS의 디렉터리 엔트리(dentry) 캐시가 종종 RCU 아래에서 접근될 수 있다면, 프로세스 트리에도 뭔가 할 수 있을 가능성이 있어 보입니다.
스레드와 프로세스 사이의 여러 연결은 cgroups로 인해 더 흥미로워집니다. cgroup을 계층으로 만드는 연결은 상당히 예상 가능해 보입니다(3.16에서 크게 재배치되고 있지만 원리는 바뀌지 않습니다).
struct list_head sibling; /* my parent's children */
struct list_head children; /* my children */
struct cgroup *parent; /* my parent */
훨씬 더 흥미로운 것은 스레드와 cgroup 사이의 연결입니다. 앞서 언급했듯이 cgroup 계층은 여러 개일 수 있고, 각 스레드는 그 각각에서 하나의 cgroup에 속합니다. 이는 M x N 매핑이 필요하므로, 몇 개의 리스트로는 충분하지 않습니다. 필요한 매핑은 두 개의 중간 자료구조, css_set과 cgrp_cset_link를 사용해 달성합니다.
![Image 2: [Cgroup hierarchy structures]](https://static.lwn.net/images/2014/cgroups-connect.png)
프로세스가 fork하면 자식은 부모가 속한 모든 동일한 cgroup에 속하게 됩니다. 둘 중 어느 쪽이든 이동될 수는 있지만, 실제로는 매우 자주 그렇지 않습니다. 즉, 여러 프로세스(그리고 그 안의 스레드)가 동일한 cgroup 집합에 속하는 경우가 매우 흔합니다. 이 공통성을 활용하기 위해 struct css_set이 존재합니다. 이는 cgroup 집합을 식별하며(css는 "cgroup subsystem state"를 뜻합니다), 각 스레드는 정확히 하나의 css_set에 연결됩니다. 모든 css_set은 해시 테이블로 연결되어, 프로세스나 스레드가 새 cgroup으로 이동할 때 요구되는 cgroup 집합을 가진 기존 css_set이 있다면 재사용할 수 있습니다.
유사한 스레드와 프로세스를 css_set으로 묶었더라도, 여전히 M x N 매핑 문제는 남아 있습니다. 다만 M이 더 이상 스레드 수가 아니라 훨씬 작은 css_set 수가 됩니다. 여러 cgroup과 여러 css_set을 연결하기 위해 적절한 이름의 cgrp_cset_link가 있습니다.
struct cgrp_cset_link {
struct cgroup *cgrp;
struct css_set *cset;
struct list_head cset_link;
struct list_head cgrp_link;
};
각 css_set에는 계층마다 하나씩 cgrp_cset_link가 있습니다. 각 cgroup에는 해당 cgroup의 모든 cgrp_cset_link를 연결하는 cset_links 필드가 있고, 이를 통해 그 cgroup에 속한 모든 스레드를 찾을 수 있습니다. 마찬가지로 각 css_set에는 그 css_set의 어떤 스레드든 포함하는 모든 cgroup을 찾을 수 있는 cgrp_links 필드가 있습니다(이 모든 list_head 때문에 머리가 어지럽다면, makelinux에 리눅스 연결 리스트에 대한 꽤 좋은 소개가 있습니다).
이 자료구조는 모든 스레드와 모든 cgroup을 꽤 효과적으로 연결합니다. 효율적으로 연결하는지는 또 다른 질문입니다. 스레드의 모든 cgroup을 찾거나, cgroup의 모든 스레드를 찾는 데는 꽤 좋습니다. 하지만 예를 들어 특정 프로세스에 대해 net_cl 서브시스템을 제공하는 cgroup을 찾는 것(새 소켓에 할당할 class ID를 결정하기 위해)에는 전혀 적합하지 않습니다.
이를 충족하기 위해 추가 연결(위 그림에는 표시되지 않음)이 있습니다. 각 css_set에는 서브시스템 번호로 인덱싱되는 포인터 배열이 들어 있어, cgrp_css_link 구조를 우회하여 각 서브시스템에 관련된 cgroup으로 직접 연결됩니다.
struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];
(cgroup_subsys_state는 cgroup과 밀접하게 연결된 서브시스템별 정보를 담고 있습니다.)
이제 이 리스트들을 관리하는 락킹을 보면, 놀라운 점이 몇 가지 있습니다. 첫째, 프로세스와 task에서 그랬던 것처럼 이 모든 연결을 보호하는 reader/writer 락이 있는데, 이 락 css_set_rwsem은 스핀락이 아니라 세마포어입니다. 이는 일반적으로 세마포어가 더 오래 유지될 수 있는 락에 쓰여, 기다리는 프로세스가(단지 스핀하는 대신) 수면할 필요가 있을 때 사용되기 때문에 놀랍습니다. 이 락의 히스토리를 살펴보면, 꽤 최근까지는 스핀락이었고, 코드를 점진적으로 정리하는 과정의 일부로 세마포어로 바뀌었습니다. 아마 다시 스핀락이 될 가능성이 있어 보입니다.
둘째 놀라움은 더 흥미롭습니다. cgroups는 스레드 그룹에 스레드가 합류하거나 떠나는 것을 막을 수 있는 또 다른 락 group_rwsem을 추가합니다. 이 락은 스레드 그룹마다 하나씩 존재하며(signal_struct에 저장됨), 배타적으로(즉 write로) 잡히는 경우가 드물어 성능에 영향을 주지는 않을 가능성이 큽니다. 하지만 일반적으로는 새 락을 피하는 것이 최선입니다. 여기서 질문이 생깁니다. 왜 스레드 리스트를 보호하기 위해 새로운 락이 필요할까요?
이 락이 필요한 이유는 cgroup이 프로세스의 그룹이 아니라 스레드의 그룹이기 때문입니다. 즉, 어떤 프로세스를 cgroup에 추가할 때 각 개별 스레드를 따로 추가해야 하며, 한 cgroup에서 제거하고 다른 cgroup에 추가하는 동안 스레드 리스트를 안정적으로 유지할 필요가 있습니다. 만약 cgroup이 진정으로 프로세스의 그룹이었다면 스레드를 개별적으로 옮길 필요가 없었을 것이고, 이 락은 버릴 수 있었을 것입니다. 스레드(프로세스나 그룹 리더가 아니라)를 나열하는 가치가 코드에서 명확하지는 않습니다. 서로 다른 스레드를 구별할 수 있다는 점을 활용할 수 있는 서브시스템이 있는지 여부라는 관점에서 여러 서브시스템을 분석하는 일은 관심 있는 독자에게 과제로 남겨 둡니다.
자료구조를 들여다보는 일의 대부분 가치는, 가능한 변경의 결과를 추론할 수 있도록 여러 요소가 어떻게 맞물리는지에 대한 그림을 더 구체화하는 데 있습니다. 그럼에도 이 탐사에서 얻을 수 있는 구체적 교훈이 몇 가지 있습니다.
첫째, 용어는 까다로울 수 있으며 커널 코드를 읽을 때 해석에 주의해야 한다는 점을 다시 상기했습니다. task는 흔히 task이고, MAX는 대체로 최대값입니다. 하지만 항상 그렇지는 않습니다.
둘째, 락킹은 까다로울 수 있습니다. 가능한 한 락을 요구하지 않도록 하는 것이 일반적으로 최선이며, 자료구조가 단순하고 우아할수록 그렇게 하기가 쉽습니다. tasklist_lock의 영향을 줄이는 것은 가능하고, 또한 바람직해 보입니다. 반면 필요하다면 cgroup 락들의 영향을 줄이는 일은 자료구조가 더 복잡하기 때문에 더 어려울 것입니다.
마지막으로, 그 복잡성의 핵심은 다중 연결된 cgroup_cset_link 구조들의 증식입니다. 지난번에 논의했듯이, 단일 계층을 사용하면서 서로 다른 자원에 대해 서로 다른 프로세스 분류 조합을 허용하기 위해 수많은 cgroup을 만든다면, cgroup_cset_link도 cgroup만큼 많이 존재하게 될 것입니다.
다르게 말하면, 우리가 우려했던 조합 폭발은 피할 수 없습니다. 수많은 cgroup으로 명시적으로 드러나든, 수많은 cgroup_cset_link 구조들로 암묵적으로 드러나든, 어쨌든 존재합니다. 현재 cgroup은 link 구조보다 훨씬 큰 구조이고, cgroup_cset_link는 mkdir 요청을 필요로 하지 않고 자동으로 생성되므로, 아마 link의 증식이 cgroup의 증식보다 비용이 더 적을 수도 있습니다.
다만 이는 우려를 다른 방식으로 표현할 수 있음을 시사합니다. 증식을 걱정하기보다는, cgroup의 크기와 cgroup을 자동으로 생성하는 데 어떤 메커니즘을 쓸 수 있는지를 걱정할 수 있습니다.
이 퍼즐을 열린 질문으로 남겨 둔 채, 우리는 작성 시점의 최신 릴리스였던 Linux 3.15에서 관찰되는 cgroup에 대해 알아낼 수 있는 것의 끝에 도달합니다. 하지만 아직 완전히 끝난 것은 아닙니다. 다음이자 마지막 편에서는 3.15를 넘어 제안된 통합(unified) 계층이 어떤 가치를 가져올지 살펴보고, 모든 핵심 쟁점을 하나의 일관된 그림으로 제시하려고 합니다.
| Index entries for this article |
|---|
| Kernel |
| GuestArticles |