-
리눅스(Linux) 기초 개념SE Concepts 2021. 5. 29. 14:12반응형
리눅스 커널 관련 책을 읽다가 200쪽을 넘어가던 중 읽기가 어려워 포기하고, 새로운 책을 찾아나섰습니다. 그러던 중 아마존에서 매우 평이 높아 읽기 시작하게된 [1]의 경우 매우 구성이 잘 되어 있어 추천하고 싶다는 생각을 하게 되었습니다.
이번 글에서는 [1]의 챕터 2를 요약하며, 리눅스를 이미 알고 있던 분들에게는 환기, 잘 모르시는 분들에게는 대략적인 소개가 될 수 있는 글을 기술해보려 합니다.
커널
보통 '운영체제'라는 용어는 2가지 다른 의미로 사용됩니다:
- 컴퓨터 리소스를 관리하는 중심 소프트웨어와 동반하는 여러 소프트웨어 도구(커맨드 라인 인터프리터, GUI, 파일 유틸리티, 에디터 등)로 포함된 패키지를 일컫는 경우
- 좀 더 좁은 개념으로 컴퓨터 리소스(CPU, RAM, 디바이스 등)를 관리하고 할당하는 중심 소프트웨어를 일컫는 경우
'커널'이라는 용어는 위의 두번째를 의미하는데에 사용되는 또 다른 용어입니다. 커널 없이 컴퓨터에서 프로그램을 실행할 수 있더라도, 커널이 존재함으로 프로그램을 작성하고 실행하는 일을 매우 단순화하고 개발자에게 유연함을 제공해줍니다. 커널은 이러한 역할을 컴퓨터라는 유한한 자원을 관리할 수 있는 소프트웨어 레이어를 제공하면서 수행하게 됩니다.
리눅스 커널 executable은 보통 path /boot/vmlinuz와 유사한 형태로 존재합니다.
커널에 의해 수행되는 tasks
커널은 다음과 같은 task를 수행합니다:
- 프로세스 스케쥴링
하나의 컴퓨터는 하나 이상의 CPU를 가집니다. 다른 UNIX 시스템과 유사하게, 리눅스는 preemptive multitasking 운영체제로, 멀티태스킹은 여러 프로세스가 동시에 메모리에 존재하며 각각이 CPU를 할당받는 것을 말합니다. Preemptive는 어떤 프로세스가 CPU 자원을 얼마나 할당받을지를 관장하는 규칙이 커널 프로세스 스케쥴러에 의해 결정된다는 것을 말합니다 (프로세스 직접 결정되는 대신).
- 메모리 관리
과거에 비하면 메모리 크기는 비약적인 발전을 이루었으나, 그에 따라 애플리케이션의 사용량도 늘어나 여전히 메모리는 커널에 의해 여러 프로세스에서 공유하며 사용되는 한정된 자원입니다. 여타 모던 운영체제와 같이 리눅스도 가상 메모리를 통해 메모리를 관리합니다. 이는 1) 각 프로세스가 다른 프로세스로부터 커널 상에서 독립되어 존재하여 독립성을 보장하며, 2) 프로세스의 일부분만 메모리에 보관하여 메모리에 좀 더 많은 프로세스들이 상주할 수 있도록 한다는 장점을 제공해 줍니다.
- 파일시스템 제공
커널은 디스크에 대한 파일시스템을 제공하며 파일이 생성, 추출, 업데이트, 삭제될 수 있도록 합니다.
- 프로세스 생성과 종료
커널은 새로운 프로그램을 메모리에 로드하고 실행에 필요한 리소스를 제공할 수 있습니다. 그렇게 실행되는 프로그램은 '프로세스'에 해당됩니다. 프로세스가 실행을 끝내면, 커널은 리소스를 거둬들여 다른 프로세스가 사용할 수 있도록 합니다.
- 디바이스에 대한 접근
컴퓨터에 연결된 디바이스(마우스, 모니터, 키보드, 디스크 등)들은 input, output 등을 통해 외부와 컴퓨터가 커뮤니케이션할 수 있도록 합니다. 커널은 인터페이스가 포함된 프로그램을 제공하여 디바이스에 대한 접근을 단순화하고 표준화합니다.
- 네트워킹
커널은 사용자 프로세스를 대신해 네트워크 메시지의 송수신을 담당합니다.
- 시스템콜 API 제공
프로세스는 커널에 시스템콜이라는 커널 접근 포인트를 통해서 커널이 다양한 task를 수행하도록 할 수 있습니다.
위의 기능을 외에도, 다수의 사용자가 컴퓨터를 사용 시 virtual private 추상화를 통해 각 사용자가 (비교적) 독립된 환경으로 시스템에 로그인할 수 있도록 합니다.
커널 모드와 유저 모드
모던 프로세서 아키텍쳐는 주로 CPU가 최소한 2가지 모드인 커널, 유저 모드로 실행될 수 있도록 합니다. 하드웨어 명령은 한 모드에서 다른 모드로 전환(switch)하도록 해줍니다. 비슷한 형태로, 가상 메모리는 유저 스페이스인지 커널 스페이스인지 나누어져서 구성되게 됩니다. 유저모드로 실행될 때, CPU는 오직 유저 스페이스로 표시된 메모리에만 접근이 가능하고 커널 스페이스에 접근이 하드웨어 익셉션이 발생하게 됩니다.
특정 연산은 오직 커널 모드로 실행되는 프로세서에 의해서만 수행될 수 있습니다. 이러한 하드웨어 디자인을 통해서 커널 스페이스에서 실행되는 운영체제는 유저 프로세스가 커널의 명령이나 데이터 구조에 접근할 수 없도록 보장할 수 있습니다.
시스템에 대한 프로세스 vs 커널 관점
매일의 프로그래밍 업무에서 우리는 보통 프로세스-기반의 관점에서 업무를 진행합니다. 그러나 커널을 이해하는 데에 있어서, 커널 중심의 관점으로 바라보는 것이 매우 도움이 됩니다. 그렇기에 두 가지 관점이 어떻게 다른이 비교를 통해 살펴보려 합니다.
운영되는 시스템은 주로 여러 프로세스들을 가집니다. 하나의 프로세스에게는 여러 일들이 비동기적으로 일어납니다. 실행되는 하나의 프로세스는 언제 해당 프로세스가 자원을 할당받을지, 다른 어떤 프로세스가 CPU에 스케쥴될지 알 수 없습니다. 시그널의 전달과 프로세스 간의 커뮤니케이션은 커널을 통해 조정되어 어느 순간 이벤트가 프로세스에게 발생할 수 있게 됩니다. 하나의 프로세스에서 발생하는 일들은 매우 명료합니다. 그 프로세스는 RAM 어디에 위치해있는지, 프로세스의 메모리 스페이스의 어떤 부분이 실제로 메모리에 올려져(resident) 있는지 알지 못합니다. 또한, 접근하는 파일이 실제로 어디에 있는지 알지 못하고 파일명을 통해 접근하게 됩니다. 프로세스는 직접 다른 프로세스와 커뮤니케이션 하지 못하고, 스스로 다른 프로세스를 생성하지 못합니다. 프로세는 역시 컴퓨터에 연결된 다른 디바이스에 직접 접근하지 못합니다.
반면에, 그러한 시스템의 중심에는 모든 것을 알고 컨트롤하는 하나의 커널이 존재합니다. 커널은 시스템에서 모든 실행되는 프로세스를 관리합니다. 커널은 어떤 프로세스가 다음에 CPU에 대한 접근 권리를 언제, 얼마나 얻을지 조정합니다. 커널은 모든 실행되는 프로세스에 대한 데이터 구조를 유지하고 업데이트 합니다. 또한, 커널은 프로그램에서 사용되는 파일명이 디스크에 존재하는 물리적 위치와 연결되도록 여러 low-level 데이터 구조를 관리합니다. 커널은 각 프로세스의 가상 메모리가 물리적 메모리와 디스크 스왑 영역과 매핑될 수 있도록 여러 데이터 구조를 관리합니다. 프로세스 간의 모든 커뮤니케이션은 커널이 제공하는 메커니즘을 통해 이뤄집니다. 프로세스의 요청과 응답에 따라, 커널은 프로세스를 생성하고 종료합니다. 커널은 input, output 디바이스와 직접 커뮤니케이션을 진행하며 필요하면 유저 프로세스에 정보를 전송하거나 받습니다.
쉘
쉘은 사용자가 타이핑한 커맨드를 읽고 그러한 커맨드에 대한 응답으로 적절한 프로그램을 실행하도록 디자인된 프로그램입니다. 그러한 프로그램은 때때로 command interpreter로 불립니다.
login shell이라는 용어는 사용자가 처음 로그인 했을 때 쉘을 실행하기 위해 생성되는 프로세스를 말합니다.
특정 운영체제에서는 커맨트 인터프리터가 커널의 주요 구성인 반면, UNIX 시스템에서 쉘은 유저 프로세스입니다. 여러 다양한 쉘이 존재하며, 같은 컴퓨터의 다른 사용자는 각각 다른 쉘을 사용할 수 있습니다. 유명한 쉘 몇 가지를 살펴보면:
- Bourne shell(sh)
가장 오래되고 널리 사용되는 쉘로 Steve Bourne에 의해 작성되었습니다. UNIX 7 에디션의 스탠다드 쉘로 여타 쉘이 가진 기능인 I/O 리다이렉션, 파이프라인, 파일명 생성, 변수, 환경변수 조작, 커맨드 substitution, 백그라운드 커맨드 실행, 함수 등을 제공합니다. 이후의 모든 UNIX 구현체는 다른 쉘에 더해 Bourne shell을 포함하고 있습니다.
- Bourne again shell(bash)
이 쉘은 GNU 프로젝트의 Bourne shell 재-구현체입니다. C, Korn shell에서 제공하는 여러 기능들을 유사하게 제공합니다. 리눅스에서 (아마) 가장 널리 사용되는 쉘입니다.
쉘은 단순히 인터랙티브한 사용을 위해서만이 아니라, 쉘 스크립트의 interpretation을 위해서도 디자인되었습니다. 이러한 목적을 위해, 각 쉘은 프로그래밍 언어와 유사한 변수, 루프, 조건문, I/O 커맨드 등을 구현하고 있습니다. 각 쉘은 유사한 task를 수행하지만 약간씩은 다른 문법을 제공하고 있습니다.
유저와 그룹
시스템의 각 유저는 유니크하게 식별되며 유저는 보통 그룹에 속합니다.
유저
시스템의 각 유저는 유니크한 로그인명과 숫자의 user ID를 가집니다. 각 유저를 위해서, 이러한 것들은 시스템 패스워드 파일인 /etc/passwd에 한 줄 씩 기록되어 아래와 같은 정보를 포함하고 있습니다:
- 그룹 ID: 해당 유저가 속한 첫 그룹의 ID
- 홈 디렉토리: 유저가 로그인 이후 위치하게 되는 시작점 디렉토리
- 로그인 쉘: 유저 커맨드를 해석하기 위해 실행되는 프로그램 명
그룹
운영 목적을 위해 보통 유저는 그룹으로 묶여집니다. 예로, 하나의 프로젝트를 위해 파일을 공유하며 작업을 진행하는 팀의 멤버들의 경우 하나의 그룹에 속할 수 있습니다. UNIX 구현 초창기에는 유저는 오직 하나의 그룹에만 소속될 수 있었습니다. 이후 BSD는 한 유저가 여러 그룹에 속할 수 있도록 하였고 UNIX, POSIX도 그러한 구조를 따라 변경되었습니다. 각 그룹은 시스템 그룹 파일인 /etc/group의 각 줄을 구성하며 아래의 정보를 포함합니다:
- 그룹명: 그룹의 유니크한 이름
- 그룹 ID: 해당 그룹에 부여된 숫자 ID
- 유저 리스트: 해당 그룹에 속한 유저명 리스트
슈퍼유저
슈퍼유저는 시스템 내에서 특별한 권한을 가집니다. 슈퍼유저 계정은 user ID 0을 가지며 보통 로그인명이 root입니다. 보통의 UNIX 시스템에서 슈퍼유저는 모든 권한 체크를 그냥 통과하게 됩니다.
단일 디렉토리 구조, 디렉토리들, 링크, 파일
커널은 시스템의 모든 파일을 조직화하기 위해 단일한 수직 디렉토리 구조를 유지합니다. 모든 파일과 디렉토리는 root 디렉토리의 children이거나 자손으로 존재하게 됩니다.
파일 타입
파일시스템 내에서, 각 파일은 파일이 어떤 종류인지 나타내는 타입이 표기됩니다. 그 중 한 가지는 일반적인(regular or plain) 파일을 나타내며 다른 타입들로는 디바이스, 파이프, 소켓, 디렉토리, 심볼릭 링크가 존재합니다.
그렇기에 '파일'이라는 용어는 일반적인 파일 타입 뿐만 아니라 위의 다양한 타입도 포함하고 있습니다.
디렉토리와 링크
디렉토리는 컨텐츠가 파일명과 그런 파일명에 해당되는 파일참조로 구성된 테이블 형태를 가진 특별한 파일입니다. 파일명-참조 관계는 '링크'라고 불리며, 파일들은 여러 링크를 가지기에 여러 파일명을 가지고 있습니다.
디렉토리는 파일 또는 다른 디렉토리에 해당되는 링크를 포함할 수 있습니다. 디렉토리 간의 링크는 위 이미지와 같은 수직 디렉토리 구조를 만들게 됩니다.
모든 디렉토리는 최소한 1) 자기 자신에 연결된 .(dot) 링크와 2) 부모 디렉토리에 연결된 ..(dot-dot) 링크 2가지를 가지게 됩니다. root 디렉토리를 제외한 모든 디렉토리는 부모 디렉토리는 가집니다.
심볼릭 링크
일반적인 링크처럼 심볼릭 링크는 하나의 파일에 대한 또 다른 이름을 제공합니다. 일반적인 링크가 디렉토리 안의 파일명-참조 엔트리인 반면, 심볼릭 링크는 특별하게 표기된 파일로 다른 파일의 이름을 포함하고 있습니다. 후자의 파일은 심볼릭 링크의 '타켓'이라고 불리며, 보통 "심볼릭 링크가 타겟 파일을 포인트 또는 참조 한다"라고 표현합니다. 시스템 콜 안에서 pathname이 명시되면, 대부분의 상황에서 커널은 자동적으로 pathname 상의 심볼릭 링크를 dereference하며 심볼릭 링크가 참조하는 파일로 대체합니다.
파일명
대부분의 리눅스 파일시스템에서, 파일명은 최대 255글자까지 가능합니다. 파일명은 /와 null 글자(\0)를 제외한 모든 캐릭터를 포함할 수 있습니다. 하지만 보통 글자와 숫자, ., _, -만 사용하는 것이 권장됩니다.
쉘 스크립트에서 사용되는 특별한 캐릭터가 파일명에 포함되게 되면, 이러한 캐릭터는 반드시 이스케이핑 되어야 합니다.
Pathnames
Pathname은 /로 시작하며, /로 분리된 연속된 파일명으로 구성된 스트링입니다. 보통 마지막 슬래쉬(/) 이전의 path는 디렉토리로 불리고, 마지막 슬래쉬 이후는 pathname의 파일 또는 base 부분이라고 불립니다.
루트 디렉토리는 나타내는 /로 시작되어, 해당되는 파일의 위치를 표시한 absolute pathname과 프로세스의 현재 작업 디렉토리로부터 시작되어 해당되는 파일의 위치를 표시한 relaitve pathname이 존재합니다.
Current working directory
각 프로세스는 하나의 '현재 작업 디렉토리'를 가집니다. 이것은 단일 수직 디렉토리 구조에서 프로세스의 현재 위치를 나타내며, 이 위치로부터 relative pathname을 계산하게 됩니다.
하나의 프로세스는 자신의 현재 작업 디렉토리를 부모 프로세스로부터 상속합니다. 로그인 쉘의 경우 처음의 '현재 작업 디렉토리'는 유저 패스워드 파일의 홈 디렉토리로 생성됩니다.
파일 소유권과 권한
각 파일은 연관된 유저 ID와 그룹 ID를 가지며 이것은 파일의 소유자와 속한 그룹을 정의하게 됩니다. 파일의 소유권은 파일 사용자가 접근 시 접근 권한을 가졌는지 확인하기 위해 사용됩니다.
파일 접근을 위해, 시스템은 사용자를 소유자, 그룹 멤버, 그 외의 3가지로 나눕니다. 3가지 종류의 사용자에 대한 접근 권한은 파일의 permission bits에 설정되며 동시에 read, write, execute이라는 3가지 접근 형태도 각 사용자 종류별로 설정을 통해 제어할 수 있게 됩니다.
파일이 디렉토리 타입인 경우 read는 디렉토리 내의 파일 리스팅, write은 디렉토리 내의 컨텐츠 변경, execute은 디렉토리 내의 파일에 대한 접근에 대한 제어를 담당합니다.
파일 I/O 모델
UNIX 시스템의 특별한 I/O 모델의 기능은 universality of I/O라는 개념입니다. 이것은 시스템 콜(open(), read(), write() 등)들이 모든 파일의 파일과 디바이스에 대한 I/O를 수행하는데에 사용된다는 것을 말합니다. 그렇기에 이러한 시스템 콜을 이용하는 프로그램은 어떤 파일에 대해서도 동작할 수 있게 됩니다.
커널은 본질적으로 연속된 바이트 스트림이라는 하나의 파일을 제공하며, lseek() 시스템 콜을 사용해 랜덤액세스가 가능합니다.
여러 애플리케이션과 라이브러리는 뉴라인 캐릭터를 텍스트 한 줄의 끝으로 해석하지만, UNIX 시스템은 end-of-file 캐릭터가 없으며 파일의 끝은 데이터를 반환하지 않는 것으로 인식되게 됩니다.
파일 디스크립터
I/O 시스템 콜은 열린 파일을 파일 디스크립터를 사용해 참조합니다. 파일 디스크립터는 주로 open() 콜을 통해 얻어지며 I/O가 행해질 파일의 pathname을 아규먼트로 받습니다.
보통 프로세스는 쉘을 통해 실행 시 3개의 열린 파일 디스크립터를 상속합니다. 디스크립터 0은 프로세스가 input을 받는 파일인 standard input이고 디스크립터 1은 프로세스가 output을 write하는 파일인 standard output이고, 디스크립터 2는 프로세스가 예외나 비정상 동작 시 에러 메시지를 적는 파일인 standard error입니다. 인터랙티브 쉘이나 프로그램에서 이러한 3개의 디스크립터는 보통 터미널과 연결되게 됩니다.
stdio 라이브러리
파일 I/O를 수행하기 위해서, 프로그램은 주로 스탠다드 c 라이브러리에 포함된 I/O 함수를 이용합니다. 이러한 함수의 세트인 stdio 라이브러리는 fopen(), fclose(), scanf(), prinf(), fgets(), fputs() 등을 포함합니다. stdio 함수들은 I/O 시스템 콜(open(), close(), read(), write() 등)의 기반해 구성되어 있습니다.
프로그램
프로그램은 보통 2가지 형태로 존재합니다. 첫 번째는 프로그래밍 언어로 작성된 구문들로 구성된 소스코드입니다. 소스코드는 실행되기 위해서 2번째 형태인 컴퓨터가 이해할 수 있는 binary machine-language instructions로 변형되어야 합니다.
프로세스
간단히 말하면, 프로세스는 실행되는 프로그램의 인스턴스입니다. 프로그램이 실행될 때, 커널은 프로그램의 코드를 가상 메모리에 로드하고 프로그램 변수를 위한 공간을 할당하고, 프로세스에 대한 다양한 정보를 기록하기 위한 데이터 구조를 준비하기 위해 커널을 셋업합니다.
커널의 관점에서 프로세스들은 다양한 컴퓨터 리소스를 공유해주어야 할 개체들입니다. 메모리와 같은 제한된 리소스를 위해, 커널은 처음에 특정 규모의 리소스를 프로세스에 할당해주고, 프로세스의 요청과 전반적인 시스템 요청을 고려하여 프로세스의 lifetime 동안 할당을 조정합니다.
프로세스 메모리 구조
하나의 프로세스는 논리적으로 아래와 같은 세그먼트로 나뉩니다:
- 텍스트: 프로그램의 instructions
- 데이터: 프로그램에서 사용되는 정적 변수들
- Heap: 프로그램이 동적으로 추가적인 메모리를 할당할 수 있는 영역
- Stack: 함수가 실행되고 반환하며 증가하고 감소하는 메모리 영역으로 지역 변수나 함수 호출 linkage 정보를 위한 스토리지 할당에 사용됨
프로세스 생성과 실행
하나의 프로세스는 fork() 시스템 콜을 사용해 새로운 프로세스를 생성할 수 있습니다. fork()를 호출하는 프로세스는 부모 프로세스로 불리며, 새롭게 생성된 프로세스는 자식 프로세스라고 불립니다. 커널은 자식 프로세스를 부모 프로세스를 복사하여 생성합니다. 자식은 부모의 데이터, 스택, 힙 세그먼트를 상속하며 이후 독립적으로 복사한 것들을 변경하게 됩니다.
자식 프로세스는 부모와 같은 코드 상에서 다른 함수들을 실행하거나 execve() 시스템 콜을 사용해 완전히 새로운 프로그램을 로드하고 실행하게 됩니다. execve() 콜은 존재하던 텍스트, 데이터, 스탭, 힙 세그먼트를 삭제하고 새로운 프로그램에 기반해 새로운 세그먼트로 대체합니다.
몇몇의 c 라이브러리 함수는 execve() 콜에 기반하여 구성되었으며, 유사한 기능을 위해 약간씩 다른 interface를 제공합니다. 이러한 모든 함수는 exec의 접두어를 가지고 있습니다.
프로세스 ID와 부모 프로세스 ID
각 프로세스는 유니크한 integer process identifier (PID)를 가집니다. 각 프로세스는 또한 부모 프로세스 ID (PPID) 속성을 가지며 누가 해당 프로세스를 생성하도록 커널에 요청했는지 알 수 있게 해줍니다.
프로세스 종료와 종료 상태
하나의 프로세스는 2가지 중 하나의 형태로 종료될 수 있습니다: 스스로 _exit() 시스템 콜을 호출하여 종료되거나 시그널의 전달을 통해 killed되는 경우. 어느 형태든, 그 프로세스는 작은 양수 integer값으로 부모 프로세스에서 wait() 콜을 통해 접근이 가능한 종료 상태값을 생성하게 됩니다.
_exit()이 실행되었을 경우 프로세스는 명시적으로 스스로의 종료 상태값을 표시합니다. 만약 프로세스가 시그널을 통해 kill되면, 종료 상태값은 프로세스를 종료시킨 시그널의 타입에 따라 다른 값을 가지게 됩니다.
컨벤션으로 0인 종료 상태값은 프로세스가 성공했음을 알리고, 0이 아닌 값은 어떠한 에러가 발생했음을 알립니다. 대부분의 쉘은 가장 마지막으로 실행된 프로그램의 종료 상태값을 변수 $?을 통해 접근할 수 있도록 해주고 있습니다.
프로세스 유저와 그룹 identifiers
각 프로세스는 연관된 유저 IDs (UIDs)와 그룹 IDs (GIDs)를 가집니다.
- 실제 유저 ID와 실제 그룹 ID
이것들은 프로세스가 속한 유저와 그룹을 식별합니다. 새로운 프로세스는 이 값을 부모로부터 상속합니다. 로그인 쉘은 상응하는 필드로부터 시스템 패스워드 파일로부터 유저 ID와 그룹 ID를 얻습니다.
- Effective 유저 ID와 effective 그룹 ID
이 2가지 ID는 파일과 프로세스 간 커뮤니케이션 객체 등과 같은 보호받는 리소스에 접근 시 프로세스가 가지고 있는 권한을 결정하게 됩니다. 일반적으로 프로세스의 effective ID는 real IDs와 같은 값을 가지게 됩니다. Effective IDs의 변경은 프로세스가 다른 유저 또는 그룹의 권한을 가지고 실행될 수 있도록 해줍니다.
- Supplementary 그룹 IDs
프로세스가 속한 추가적인 그룹들을 식별하는데 사용되는 ID입니다. 새로운 프로세스는 부모로부터 상속을 받고, 로그인 쉘은 시스템 그룹 파일로부터 이 값을 얻어오게 됩니다.
Privileged 프로세스
전통적으로 UNIX 시스템에서 privileged 프로세스는 effective 유저 ID가 0인 프로세스입니다. 그러한 프로세스는 모든 권한 제약에서 벗어나게 됩니다. 반대로, unprivileged는 다른 유저에 의해 실행되는 프로세스에 적용됩니다. 그러한 프로세스는 0이 아닌 effective 유저 ID를 가지며 반드시 커널에 의해 강제되는 권한 규칙에 제약을 받습니다.
보통 privileged 프로세스는 다른 privileged 프로세스로(예로, root로 시작된 로그인 쉘)부터 실행됩니다.
Capabilities
커널 2.2부터, 리눅스는 전통적으로 슈퍼유저에 부여된 privileges를 구분된 단위인 capabilities로 나누었습니다. 각 privileged 연산은 특정한 capability에 연결되어 프로세스가 특정한 capability를 가질 경우에만 연관된 연산을 실행할 수 있습니다. 전통적인 슈퍼유저 프로세스는 모든 capabilities를 가진 프로세스와 같습니다.
capabilities의 일부를 특정 프로세스에 허락하는 것은 슈퍼유저가 행할 수 있는 특정 연산을 가능하게하면서, 다른 허락되지 않은 capabilities는 수행할 수 없도록 합니다.
init 프로세스
시스템 부팅 시, 커널은 init이라고 불리는 특별한 프로세스를 생성합니다. 이 init 프로세스는 /sbin/init에 존재하는 프로그램으로부터 얻어지며 모든 프로세스들의 부모 프로세스에 해당됩니다. 시스템의 모든 프로세스는 init에 의해 생성되거나, init의 자손 중에 하나에 의해 생성되게 됩니다. init 프로세스는 언제나 프로세스 ID 1을 가지며 슈퍼유저 권한을 가지고 실행되게 됩니다. init 프로세스는 killed 될 수 없고, 시스템이 종료될 때에만 끝나게 됩니다. init의 주요 task는 실행되는 시스템에 의해 요구되는 여러 프로세스들을 생성하고 모니터링하는 것입니다.
데몬 프로세스
daemon은 특별한 목적의 프로세스로 시스템에 의해 다른 프로세스와 동일하게 생성되고 다뤄지지만 아래와 같은 특징을 가진 프로세스에 해당됩니다:
- 오랜 기간 실행됨. 보통 시스템 부팅 시 실행되어 시스템 종료 시까지 실행되게 됨.
- 백그라운드에서 실행됨. input을 읽거나 output을 write할 컨트롤 터미널을 가지지 않음
예로, 시스템 로그를 기록하는 syslogd나 웹 서버인 httpd 등이 해당됩니다.
환경 리스트
각 프로세스는 환경변수들의 모음으로 프로세스의 유저 스페이스 메모리에 존재하는 환경 리스트를 가집니다. 리스트의 각 요소들은 이름과 해당되는 값으로 이루어져 있습니다. 새로운 프로세스가 fork()를 통해 생성되면, 부모의 환경을 상속받습니다. 그러므로 그 환경은 부모 프로세스가 자식 프로세스에게 정보를 전달해줄 수 있는 메커니즘을 제공하게 됩니다. 프로세스가 실행되던 프로그램을 exec*()를 사용해 대체하게 되면, 새로운 프로그램은 새로운 환경을 받거나 기존의 프로그램이 사용하던 환경을 상속하게 됩니다.
환경변수는 대부분의 쉘에서 export 커맨드를 통해 생성됩니다.
C 프로그램은 외부 변수(environ)을 사용해 환경에 접근할 수 있고 다양한 라이브러리 함수는 프로세스가 환경의 값을 추출하고 변경할 수 있도록 제공해줍니다.
환경변수는 여러 목적을 위해 사용됩니다. 예로, 쉘은 스크립트와 프로그램이 접근할 수 있는 여러 환경변수를 정의하고 사용합니다. HOME과 같은 변수는 사용자의 로그인 디렉토리 pathname을 기록하고, PATH와 같은 변수는 쉘이 커맨드에 상응하는 프로그램을 찾을 때 탐색해야하는 디렉토리들을 명시하는 데에 사용되게 됩니다.
리소스 제한
각 프로세스는 파일, 메모리, CPU 시간과 같은 리소스를 소비합니다. setrlimit() 시스템 콜을 사용하여 하나의 프로세스는 여러 리소스에 대한 최대 소비량을 설정하고 제한할 수 있습니다. 각 리소스 제한은 프로세스가 소비하는 리소스양을 제한하는 소프트 limit과 소프트 limit의 최대양을 제한하는 하드 limit을 가집니다. Unprivileged 프로세스는 해당되는 소프트 limit을 리소스에 대해 0에서부터 하드 limit까지 조정할 수 있으나, 하드 limit은 오직 낮출 수만 있습니다.
새로운 프로세스가 fork()를 통해 생성되리 때, 부모 프로세스의 리소스 제한 설정의 복사본을 상속합니다.
쉘의 리소스 제한은 ulimit 커맨드를 사용해 조정되고, 이러한 제한 설정은 쉘이 커맨드를 실행하기 위해 생성하는 프로세스가 상속하게 됩니다.
메모리 매핑
mmap() 시스템 콜은 호출하는 프로세스의 가상 주소 스페이스에 새로운 메모리 매핑을 생성합니다. 매핑은 아래 2가지 카테고리 중 하나에 속하게 됩니다:
- 파일 매핑: 파일의 특정 부분을 호출하는 프로세스의 가상 메모리에 매핑함. 일단 매핑되면 상응하는 메모리 부분에 존재하는 바이트에 대한 연산을 통해 파일 컨텐츠에 접근할 수 있음. 필요한 시점에 파일로부터 매핑 페이지로 자동적으로 로딩됨.
- Anonymous 매핑: 상응하는 파일을 가지지 않고 매핑의 페이지가 0으로 초기화 됨.
한 프로세스의 매핑에 존재하는 메모리는 다른 프로세스에 존재하는 매핑과 공유될 수 있습니다. 이러한 공유는 두 개의 프로세스가 파일의 똑같은 부분을 매핑하거나 fork()로 생성된 자식 프로세스가 부모로부터 매핑을 상속하기 때문에 발생하게 됩니다.
두 개 이상의 프로세스가 같은 페이지를 공유하면, 매핑이 private 또는 shared로 생성되었는지에 따라 각 프로세스는 다른 프로세스가 페이지에 변경을 가하였을 때 그러한 변경을 확인할 수 있습니다. 매핑이 private일 때에, 매핑 컨텐츠에 대한 변경은 다른 프로세스에 보이지 않으며 파일에도 반영되지 않습니다. 매핑이 shared일 때에는, 매핑 컨텐츠에 대한 변경은 다른 프로세스에 보이며 파일에도 반영되게 됩니다.
메모리 매핑은 실행 파일의 상응하는 세그먼트로부터 얻은 프로세스 텍스트 세그먼트의 초기화, 새로운 메모리 할당, 파일 I/O와 프로세스 간 통신 등 다양한 목적에 사용되게 됩니다.
Static 및 Shared 라이브러리
Object 라이브러리는 애플리케이션 프로그램에 의해 호출되는 함수들을 위한 컴파일 된 객체 코드를 포함하는 파일입니다. 그러한 함수들을 하나의 단일한 객체 라이브러리로 모아두는 것은 프로그램 생성과 유지를 쉽게 만들어 줍니다. 모던 UNIX 시스템은 static 라이브러리와 shared 라이브러리라는 2가지 타입의 객체 라이브러리를 제공합니다.
Static 라이브러리
정적 라이브러리는 초기 UNIX 시스템에 유일하게 존재하던 라이브러리였습니다. 정적 라이브러리는 컴파일된 객체 모듈의 구조화된 번들입니다. 정적 라이브러리에 있는 함수를 사용하기 위해서, 프로그램 빌드에 사용되는 링크 커맨드에 라이브러리를 명시합니다. 메인 프로그램의 다양한 함수 레퍼런스를 정적 라이브러리 모듈로 resolve한 후에, 링커는 라이브러리가 필요로 하는 객체 모듈 복사본을 추출하고 결과로 실행 파일에 복사하게 됩니다. 이러한 프로그램의 경우 정적으로 링크드되었다고 말합니다.
이러한 복사를 통한 링크는 몇 가지 단점을 가집니다. 그 중 하나는 다른 실행 파일에서 객체 코드 중복이 발생하여 디스크 공간을 낭비하는 부분입니다. 이어서 비슷한 형태로 똑같은 라이브러리를 사용하는 다른 프로그램 간에도 중복이 발생하여 메모리 낭비가 발생합니다. 또한, 정적 라이브러리가 변경된 후에는 그 라이브러리를 사용하는 모든 프로그램은 정적 라이브러리를 다시 링크하여야 합니다.
Shared 라이브러리
공유 라이브러리는 정적 라이브러리의 문제를 개선하기 위해 고안되었습니다.
공유 라이브러리로 프로그램이 링크되면, 라이브러리의 객체 모듈을 복사하는 대신 링커는 실행 파일에 레코드를 기록하여 런타임에 실행 파일이 공유 라이브러리를 사용할 수 있도록 합니다. 실행파일이 런타임에 메모리로 로드되면, dynamic 링커라는 프로그램이 실행파일이 요구하는 공유 라이브러리를 찾고 메모리로 로드되도록 처리하며 실행파일의 함수 콜이 상응하는 공유 라이브러리의 정의에 따라 실행되도록 런타임 링킹을 수행합니다. 런타임 시에, 단지 공유 라이브러리의 한 복사본만 메모리에 상주하면 됩니다. 모든 프로그램은 그것에 접근하여 사용할 수 있기 때문입니다.
디스크 공간을 절약함과 동시에 프로그램이 가장 최신 버젼 공유 라이브러리를 사용할 수 있도록 변경하기가 매우 쉽다는 장점을 가집니다.
Interprocess 커뮤니케이션과 Synchronization
실행되는 리눅스 시스템은 대부분은 독립적으로 실행되는 여러 프로세스들로 구성되어 있습니다. 그러나 특정 프로세스들은 어떠한 목적을 이루기 위해 협업하며 이러한 프로세스들은 서로 커뮤니케이션하고 처리 상태를 동기화할 필요가 있습니다.
프로세스들이 커뮤니케이션하는 한 가지 방법은 디스크 파일을 통해 정보를 읽고 쓰는 것입니다. 그러나 많은 애플리케이션에서 이 방법은 매우 느리고 유연하지 못합니다.
그러므로, 리눅스는 다른 모던 UNIX 구현체와 마찬가지로 다양한 interprocess communication (IPC) 방법을 제공합니다:
- signals: 이벤트의 발생을 알리기 위해 사용됨
- pipes와 FIFOs: 프로세스 간에 데이터를 송신하기 위해 사용됨
- sockets: 같은 호스트 컴퓨터 또는 네트워크로 연결된 다른 호스트 컴퓨터 간에 위치한, 하나의 프로세스에서 다른 프로세스로 데이터를 송신하기 위해 사용됨.
- file locking: 프로세스가 파일의 특정 영역에 lock을 허락하여 다른 프로세스가 파일을 읽고 쓰는 것을 막음.
- message queues: 프로세스 간에 메시지 교환 시 사용됨.
- semaphores: 프로세스 액션을 동기화하기 위해 사용됨.
- shared memory: 두 개 이상의 프로세스가 메모리 영역을 공유하도록 함.
UNIX 시스템 상의 넓고 많은 종류의 IPC는 때때로 겹치는 기능을 가지기도 합니다. 이는 UNIX 시스템 진화 과정의 다양한 변형으로 인해서 또는 여러 표준의 요구사항으로 인해서 그렇습니다.
시그널
이전 섹션에서 IPC의 한 방법으로 시그널을 이야기하였으나, 시그널은 보통 다른 다양한 컨택스트 상에서 이용됩니다.
시그널은 보통 "소프트웨어 인터럽트"로 언급되기도 합니다. 시그널의 도착은 프로세스에게 특정 이벤트 또는 예외 조건이 발생했음을 알립니다. 다양한 종류의 시그널이 존재하며, 각각은 다른 이벤트나 조건을 알립니다. 각 시그널 타입은 다른 integer 값으로 나타내어지며, SIGxxxx 형태의 심볼릭 이름으로 정의됩니다.
시그널은 다른 프로세스나 프로세스 자신에 의해 커널을 통해서 프로세스로 보내지게 됩니다. 예로, 커널은 아래와 같은 상황에서 프로세스에 시그널을 보내게 됩니다:
- 사용자가 interrupt 캐릭터를 입력했을 때 (보통 Control-C)
- 프로세스의 자식 중 하나가 종료되었을 때
- 프로세스에 의해 설정된 타이머가 만료되었을 때
- 프로세스가 유효하지 않은 메모리 주소로 접근하려 할 때
쉘 안에서 kill 커맨드는 프로세스에 시그널을 보내는데 사용할 수 있습니다. kill() 시스템 콜은 프로그램 내에서 동일한 기능을 제공합니다.
프로세스가 시그널을 받게되면, 아래와 같은 액션들 중 하나를 수행하게 됩니다:
- 시그널을 무시함
- 시그널에 의해 kill됨
- 특별한 목적의 시그널을 받아 다시 실행되기 전까지 일시중단됨
대부분의 시그널 타입에서는 디폴트 시그널 액션을 받아들이기 보다는 프로그램이 시그널을 무시하거나 signal handler를 만들어서 처리하게 됩니다. 시그널 핸들러는 프로그래머가 정의한 함수로 프로세스에 시그널이 도달하면 자동적으로 실행되게 됩니다. 그리고 이 함수는 각 시그널에 맞는 적절한 액션을 취하게 됩니다.
시그널이 발생하고 도달하기까지의 간극을 프로세스에 대해 시그널이 펜딩되었다고 말합니다. 보통 펜딩 시그널은 프로세스가 다음에 실행되도록 스케쥴 되었을 때나 (만약 프로세스가 실행되고 있다면) 바로 프로세스에 전달되게 됩니다. 그러나 프로세스의 시그널 마스크에 시그널을 더해 시그널을 막는 것도 가능합니다. 막힌 동안 생성된 시그널은 unblock 되기 전까지 펜딩되게 됩니다.
쓰레드
모던 UNIX 구현에 있어서, 각 프로세스는 여러 실행 쓰레드를 가질 수 있습니다. 쓰레드들을 바라보는 한 가지 방법은 같은 가상 메모리와 여러 특성들을 공유하는 프로세스들의 묶음으로 바라보는 것입니다. 각 쓰레드는 같은 프로그램 코드를 실행하고 같은 데이터, 힙 영역을 공유합니다. 그러나 각 쓰레드는 각각의 스택을 가지며 로컬 변수나 함수 콜 링크 정보를 저장합니다.
쓰레드는 공유하는 전역변수를 통해서 서로 커뮤니케이션할 수 있습니다. 쓰레딩 API는 조건변수와 뮤텍스를 제공하며 한 프로세스의 쓰레드들이 커뮤니케이션하고 공유 변수 및 액션을 동기화할 수 있도록 도와줍니다. 쓰레드는 또한 서로 IPC와 동기화 메커니즘을 통해 통신할 수 있습니다.
쓰레드 사용의 가장 큰 장점은 협업하는 쓰레드간에 데이터 공유를 쉽게 만들며 그러한 부분은 몇몇의 알고리즘의 경우 멀티-프로세스 구현보다는 멀티-쓰레드 구현이 더욱 적절한 선택이 되도록 합니다. 더욱이, 멀티쓰레드 앱은 멀티프로세스 하드웨어에서 병렬처리의 이점을 보다 투명하게 누릴 수 있습니다.
프로세스 그룹과 쉡 작업 컨트롤
쉡에 의해 실행되는 각 프로그램은 새로운 프로세스로 시작됩니다. 예로, 쉘은 아래 커맨드를 실행하기 위해 3개의 프로세스를 생성합니다:
ls -l | sort -k5n | less
Bourne shell을 제외한 대부분의 쉘은 job control이라고 불리는 인터랙티브 기능을 제공합니다. 이 기능은 사용자가 동시적으로 여러 커맨드나 파이프라인을 실행하고 조작할 수 있도록 합니다. Job-control 쉘에서, 파이프라인 안의 모든 프로세스들은 새로운 process 그룹 또는 job에 속하게 됩니다. 프로세스 그룹에 속한 각 프로세스는 같은 그룹 안의 한 프로세스(프로세스 그룹 리더)와 같은 PID 값인 process group identifier integer 값을 가집니다.
커널은 프로세스 그룹 내의 모든 프로세스에서 실행할 수 있는 다양한 액션(시그널 전송 등)을 제공합니다. Job-control 쉘에서 이러한 기능을 사용해 사용자가 파이프라인 내의 모든 프로세스들을 멈추거나 재시작할 수 있습니다.
세션, 터미널 컨트롤, 프로세스 컨트롤
세션은 프로세스 그룹의 묶음입니다. 한 세션에 속한 모든 프로세스들은 똑같은 session identifier를 가집니다. 세션 리더는 세션을 생성한 프로세스로 그 PID는 session ID가 됩니다.
세션은 job-control 쉘에서 주로 사용됩니다. job-control 쉘로 생성된 모든 프로세스 그룹은 세션 리더인 쉘과 같은 세션에 속하게 됩니다.
세션은 보통 연관된 controlling 터미널은 가지고 있습니다. Cotrolling 터미널은 세션 리더 프로세스가 터미널 디바이스를 처음으로 open할 때 구성되게 됩니다. 인터랙티브 쉘을 통해 생성된 세션에서는, 이러한 터미널은 사용자가 로그인한 터미널입니다. 하나의 터미널은 최대 한 개의 세션에 대한 controlling 터미널이 되게 됩니다.
Controlling 터미널을 open한 결과로, 세션 리더는 터미널에 대한 controlling 프로세스가 되게 됩니다. Controlling 프로세스는 만약 터미널 disconnect 발생 시 SIGHUP 시그널을 받게 됩니다.
언제든지, 세션 내의 한 프로세스 그룹은 foreground 프로세스 그룹으로 터미널에서 input을 읽고 output을 보내게 됩니다. 만약 사용자가 interrupt 캐릭터(Control-C)를 타이핑하면, 터미널 드라이버는 그 foreground 프로세스 그룹을 kill하거나 일시중단하는 시그널을 보내게 됩니다. 세션은 여러 개의 background 프로세스 그룹(커맨드 끝에 &를 더해 생성 가능)을 가질 수 있습니다.
Job-control 쉘은 모든 잡을 리스팅하거나, job에 시그널을 보내거나 job을 foreground 또는 background로 이동시키는 커맨드를 제공합니다.
Pseudoterminals
pseudoterminal은 연결된 가상 디바이스 짝으로 master와 slave로 알려져 있습니다. 이 디바이스 짝은 IPC 채널을 제공하여 두 디바이스 간에 데이터 송수신이 가능하도록 해줍니다.
pseudoterminal의 핵심은 slave 디바이스는 터미널처럼 행동하는 interface를 제공하여 터미널-oriented 프로그램이 slave 디바이스에 연결될 수 있도록 하고 master 디바이스에 연결된 다른 프로그램을 사용해 터미널-oriented 프로그램을 동작시킬 수 있도록 한다는데에 있습니다.
드라이버 프로그램에 의해 만들어진 output은 터미널 드라이버에 의해 수행되는 일반적인 input 프로세싱을 거치고 이후 slave에 연결된 터미널-oriented 프로그램의 input으로 들어가게 됩니다. 그리고 터미널-oriented프로그램이 slave에 write하는 것은 드라이버 프로그램에 input으로 들어가게 됩니다. 다른 시각으로 보면, 드라이버 프로그램이 보통 기존의 터미널 환경에서 사용자가 행하는 작업을 수행하는 역할을 하게 됩니다.
pseudoterminal은 다양한 애플리케이션에 사용되며 예로는 X Window System 로그인, telnet과 ssh 같은 네트워크 로그인 서비스가 있습니다.
Data과 Time
두 가지 형태의 time이 프로세스와 관련해 고려됩니다:
- Real time: 특정 표준점(calendar time) 또는 특정 고정점(보통 프로세스 시작 시간)으로 부터 측정됩니다. UNIX 시스템에서 calandar time은 1970년 1월 1일 00:00:00를 기준으로 초당 측정됩니다. 이 시점을 보통 Epoch이라고 부릅니다.
- Process time: 다른 말로는 CPU time으로 불립니다. 프로세스가 시작 이후 사용한 총 CPU time에 해당됩니다. CPU time은 한 단계 더 나아가면 커널 모드 실행 시간에 해당되는 system CPU time과 유저 모드 실행 시간에 해당되는 user CPU time으로 나뉩니다.
클라이언트-서버 아키텍쳐
리눅스의 다양한 부분에서 클라이언트-서버 아키텍쳐 관점에서 설명되곤 합니다.
클라이언트-서버 애플리케이션은 2개의 컴포넌트 프로세스로 나뉘어 집니다:
- client: 서버에게 요청 메시지를 보내어 특정 서비스를 수행해줄 것을 요구함
- server: client의 요청을 확인하고 적절한 액션을 수행하고, 이어 client에게 응답 메시지를 돌려줌
Realtime
realtime 애플리케이션은 input에 대해 적절한 시간 안에 응답해야 하는 애플리케이션입니다.
비록 많은 실시간 애플리케이션이 input에 대해 빠른 응답을 주어야 하지만, 결정적인 요소는 응답이 이벤트 트리깅 이후 특정 dealine time 안에 전달되도록 보장할 수 있느냐의 여부입니다.
실시간성에 대한 보장(특히 짧은)이 필요한 시스템은 운영체제 단의 지원이 필요하게 됩니다. 대부분의 운영체제는 native하게 그러한 지원을 하고 있지 않은데 그 이유는 실시간성에 대한 요구사항이 멀티유저 time-sharing 운영체제의 요구사항과 상반될 수 있기 때문입니다. 전통적인 UNIX 구현체는 실시간 운영체제가 아니며, 일부 실시간을 위한 변형 케이스가 존재하긴 합니다. 최근의 리눅스 커널은 실시간 애플리케이션에 대한 full native support를 지향하고 있습니다.
POSIX.1b는 POSIX.1에서 실시간 애플리케이션을 위한 여러 지원 extensions을 정의하였습니다. 비동기 I/O, 공유 메모리, 메모리-mapped 파일, 메모리 locking, 실시간 clocks와 타이머, alternative 스케쥴링 policies, 실시간 시그널, 메시지 큐, 세마포어가 그러한 extensions에 포함됩니다.
이러한 부분들이 엄격하게 실시간을 만족하진 않지만, 대부분의 UNIX 구현체들은 이제 이러한 extensions들을 많이 지원합니다.
/proc 파일시스템
다른 몇몇 UNIX 구현체와 유사하게, 리눅스는 /proc 파일 시스템을 제공합니다.
/proc 파일 시스템은 가상 파일 시스템으로 파일 시스템 상의 파일과 디렉토리처럼 보이는, 커널 데이터 구조에 대한 interface를 제공합니다. 이것은 쉽게 다양한 시스템 속성을 변경하고 확인할 수 있도록 해줍니다. 게다가, /proc/PID 형태의 구조를 가진 디렉토리들은 시스템에서 실행되는 각 프로세스 정보를 확인할 수 있도록 해줍니다.
/proc 파일 시스템의 컨텐츠는 일반적으로 사람이 읽을 수 있는 텍스트 형태이며 쉘 스크립트로 파싱할 수 있습니다. 프로그램도 간단히 /proc 내의 파일을 열거나 쓸 수 있으며, 보통 해당 디렉토리 내의 파일에 접근하기 위해 프로세스는 privileged로 실행되어야 합니다.
Reference
[1] The Linux Programming Interface
반응형'SE Concepts' 카테고리의 다른 글
어니언 라우팅(Onion routing)이란? (0) 2022.07.03 Two-Phase Commit이란? (2PC) (0) 2021.10.07 분산처리엔진이란? (Distributed Execution Engine) (0) 2021.08.29 Red-Black Tree란? (1) 2021.08.22 대규모 시스템에서 발생하는 데이터 처리 (feat. 패스트캠퍼스 온라인 'THE RED : 4천만 MAU를 지탱하는 서비스 설계와 데이터 처리 기술') (0) 2021.05.19 확장성 및 안정성 있는 시스템에 관하여 (feat. 패스트캠퍼스 온라인 'THE RED : 4천만 MAU를 지탱하는 서비스 설계와 데이터 처리 기술') (0) 2021.05.13 품질의 집 (House of Quality) (0) 2021.04.05 DDD란? ((Business) Domain-Driven (Software) Design) (2) 2021.03.28