Rust - Process & Thread Management
프로세스 및 쓰레드의 기본 개념에 대해 알아보고, Rust에서 이들을 관리하는 방법을 이 글에서 다뤄보겠습니다.
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
- 13. Rust - I/O Management
- 14. Rust - Process & Thread Management
- 15. Rust - Inter-Process Communication
프로세스는 메모리에 올라와서 실행중인 프로그램을 뜻하지만, 한 프로그램이 여러 개의 프로세스가 될 수도 있습니다(대표적으로 브라우저). 이때는 메모리의 Text 섹션은 동일하지만, data/heap/stack에 있는 데이터는 각각 독립적이겠죠.
Process State Transitions
네 매우 중요한 그림입니다.
-
New: 프로세스가 생성되었지만 아직 스케줄링되지 않은 상태
-
Ready: 프로세스가 스케줄링되기를 기다리는 상태
-
Running: 프로세스가 CPU를 사용하여 실행중인 상태입니다. 한 CPU 코어당 오직 하나의 프로세스만 점유할 수 있습니다. 다른 프로세스는 Ready 상태에서 대기합니다. 이 상태에서 interrupt가 발생하면, 프로세스는 Ready 상태로 전환됩니다.
-
Waiting: 프로세스가 어떤 이벤트를 기다리는 상태입니다. 이벤트가 발생하면 Ready 상태로 전환됩니다.
-
Terminated: 프로세스가 실행을 마친 상태입니다.
Ready와 Waiting이 헷갈릴 수 있으니 주의하세요. Ready는 CPU를 기다리는 상태이고, Waiting은 이벤트(특히 유저의 입력 등)를 기다리는 상태입니다.
Process Control Block(PCB)
각 프로세스는 자기 자신의 여러 정보를 저장하는 Process Control Block(PCB)를 가지고 있습니다. PCB에는 프로세스의 상태, 프로그램 카운터, 레지스터, 메모리 할당 정보, 입출력 상태 등이 저장되어 있습니다. 한 마디로, 프로세스를 시작 또는 재개하기 위한 모든 데이터를 저장하고 있는 자료구조입니다.
Multiprogramming
CPU를 절대로 idle상태로 만들지 않고 항상 busy하게 만들기 위해 여러 프로세스를 동시에 실행하는 것을 Multiprogramming이라고 합니다. 이를 위해 CPU 스케줄링을 통해 여러 프로세스를 번갈아가며 실행합니다.
Multitasking(Time Sharing)
Multiprogramming과 비슷한 개념이지만, 여러 프로세스가 동시에 실행되는 것처럼 보이는 것을 Multitasking이라고 합니다. 이는 CPU가 매우 빠르게 프로세스를 번갈아가며 실행하기 때문에 가능합니다.
Multiprogramming은 CPU가 노는 것을 방지하기 위해 남는 시간을 활용하는 것이고, Multitasking은 반대로 부족한 시간을 쪼개는 것으로, 사용자의 수요에 따라 여러 프로그램을 동시에 실행하는 것처럼 보이게 하는 것입니다.
Context Switch 등 자세한 내용은 이전에 작성한 Process Scheduling 게시글을 참조하세요.
프로세스 생성
Unix 기반 OS에서는 init
프로세스(systemd
라고도 불림)가 모든 유저 프로세스의 root parent process로서 기능합니다. 동일한 부모 프로세스를 갖는 자식 프로세스들을 sibling이라고 합니다. 그냥 트리를 생각하시면 됩니다.
fork()
시스템 콜
Program counter를 포함하여 실행 시점에 PID를 제외하고 거의 완전히 동일한 프로세스를 복제하여 새 프로세스를 생성합니다. 심지어 부모의 주소 공간이나 CPU 레지스터 값도 복사됩니다. 이때 부모 프로세스는 자식 프로세스의 PID를 반환받고, 자식 프로세스는 0을 반환받습니다.
사실
fork()
시스템 콜의 탄생 비화는 초창기 리눅스의 자원 절약을 위해서였다고 하네요. 그래서 현대 OS에서는 다소 기형적인 방식으로 여겨지기도 합니다. 물론 경로 의존성에 의해 여전히 널리 사용되고 있지만요. 보안 문제도 내포하고 있어 Rust에서는 이 시스템 콜을 사용하기 위해서는 반드시unsafe
블록의 사용이 강제됩니다.
fork 후에 부모 프로세스는 wait()
을 써서 자식 프로세스를 기다릴 수도 있고, 병렬로 실행을 계속할 수도 있습니다. shell이나 GUI는 이 시스템 콜을 내부적으로 호출합니다.
Rust에서는 libc
또는 nix
crate를 사용하여 fork()
를 호출할 수 있습니다. libc
crate는 C 라이브러리를 Rust에서 사용할 수 있도록 해주는 crate입니다.
use libc::fork;
fn main() {
let pid: i32;
println!("Ready to fork...");
unsafe {
pid = fork();
}
println!("Forked! PID: {}", pid);
}
위 프로그램을 실행하면 fork()
호출 후에는 부모와 자식, 두 개의 프로세스가 실행되며, 하나는 자식 프로세스의 PID, 하나는 0을 출력한 후 프로그램이 종료됩니다.
libc
대신 nix
를 사용하면 시스템 콜에 대한 high-level abstraction API를 제공하기 때문에 더 안전하고 더 Rust 표준에 적합한 인터페이스를 이용할 수 있습니다.
use nix::unistd::{fork, ForkResult};
use std::process::exit;
fn main() {
match unsafe { fork()} {
Ok(ForkResult::Parent { child, .. }) => {
println!("Parent: Child PID: {}", child);
}
Ok(ForkResult::Child) => {
println!("Child: I'm the child!");
}
Err(_) => {
eprintln!("Fork failed!");
exit(1);
}
}
}
프로세스 이미지 교체
fork()
로 생성된 프로세스는 부모 프로세스와 동일한 프로세스 이미지를 갖고 있지만, 완전히 다른 프로세스 이미지로 교체하여 사실상 다른 프로그램을 실행하도록 할 수도 있습니다. 이를 위해 exec()
시스템 콜을 사용합니다.
exec()
으로 교체한 프로세스는 PID는 유지되지만 메모리 공간, 레지스터 등 모든 정보가 새로운 프로세스 이미지로 교체됩니다.
Rust에서는 fork()
와 마찬가지로 libc
, nix
crate를 통해 exec()
시스템 콜을 호출할 수 있습니다.
하지만 nix
crate를 사용하면 unsafe
블록을 사용하지 않아도 되므로 더 안전하게 사용할 수 있습니다.
use nix::unistd::{execvp, fork, ForkResult};
use nix::Error;
use std::process::exit;
fn main() {
match unsafe { fork() } {
Ok(ForkResult::Parent { child, .. }) => {
println!("Parent: Child PID: {}", child);
}
Ok(ForkResult::Child) => {
println!("Child: I'm the child!");
match execvp(&"ls", &["ls"]) {
Ok(_) => {}
Err(e) => {
eprintln!("Exec failed: {}", e);
exit(1);
}
}
}
Err(e) => {
eprintln!("Fork failed: {}", e);
exit(1);
}
}
}
wait()
시스템 콜
부모 프로세스가 자식 프로세스의 종료를 기다리기 위해 사용하는 시스템 콜입니다. 이걸 호출한 프로세스는 자신의 실행을 중지하며, 자식 프로세스가 종료되면 자식 프로세스의 종료 상태를 반환하고 실행을 재개합니다. 마찬가지로 libc
또는 nix
crate를 사용하여 호출할 수 있으며 nix
crate로 호출하면 unsafe
블록을 사용하지 않아도 됩니다.
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
fn main() {
let pid = unsafe { libc::fork() };
match pid {
0 => {
println!("I'm the child!");
}
_ => {
println!("I'm the parent!");
let status = waitpid(pid, None, Some(WaitPidFlag::empty()));
match status {
Ok(WaitStatus::Exited(_, code)) => {
println!("Child exited with code: {}", code);
}
Ok(_) => {
println!("Child did not exit successfully");
}
Err(e) => {
eprintln!("Error waiting for child: {}", e);
}
}
}
}
}
waitpid()
를 사용하면 더 섬세한 제어가 가능합니다. waitpid()
함수는 PID를 인자로 받아 해당 PID의 자식 프로세스가 종료될 때까지 기다립니다. 또 WaitPidFlag
를 이용하여 자식 프로세스가 특정 상태로 종료할 때까지 기다리도록 하는 것도 가능합니다.
exit()
시스템 콜
C에서의 exit()
함수와 같이 Rust에서도 std::process::exit()
함수를 이용하여 프로세스를 종료할 수 있습니다.
정상적으로 종료되지 않은 프로세스는 다음과 같은 유형이 있습니다.
-
Zombie process: 자식 프로세스가 종료되었지만 부모 프로세스가
wait()
를 호출하지 않아 종료 상태를 확인하지 못하는 프로세스 -
Orphan process: 부모 프로세스가
wait()
호출 없이 종료되어, 자식 프로세스가 종료 상태를 부모 프로세스에게 전달하지 못하는 프로세스입니다. 몇몇 OS는 cascading termination을 구현하여 고아를 남기지 않습니다. UNIX와 Linux에서는init
프로세스가 고아 프로세스를 입양하여 주기적으로wait()
을 호출합니다.
std::process::Command
외부 명령을 실행 가능하게 하는 Rust의 유용한 표준 라이브러리입니다. 하나의 호출에 fork()
, exec()
, wait()
등을 모두 포함하고 있어 사용자가 직접 이들을 호출할 필요가 없습니다. 무엇보다, unsafe
블록을 사용하지 않아도 되므로 안전하게 사용할 수 있습니다.
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
fn main() {
let output = Command::new("ls")
.arg("-l")
.output()
.expect("Failed to execute command");
if output.status.success() {
println!("Output: {}", String::from_utf8_lossy(&output.stdout));
} else {
println!("Error: {}", String::from_utf8_lossy(&output.stderr));
}
}
Thread Management
쓰레드란 CPU 활용의 기본 단위(execution unit, execution flow)로, 각 쓰레드는 쓰레드 ID, 그리고 독립된 CPU 레지스터, 그리고 독립된 각각의 스택 공간을 할당 받습니다. 그래서 여러 쓰레드가 동시에 병렬적으로 실행이 가능합니다. 단 Code, Data, Heap 영역은 한 프로세스 내에서 서로 공유합니다.
다시 말해, 지역 변수는 쓰레드 간 공유되지 않지만, 전역 변수와 heap에 할당된 동적 객체들은 쓰레드 간 공유됩니다.
쓰레도드 유저 쓰레드와 커널 쓰레드로 나뉘어지며, 그 대응 관계에 따라 One-to-one, many-to-one, many-to-many 모델로 나뉩니다. 대부분의 OS는 One-to-one 모델을 사용합니다. Many-to-one 모델에서 유저 쓰레드 개수가 많을 경우에는 리소스 효율성이 올라가지만, 남는 유저 쓰레드가 다른 커널쓰레드를 이용중인 유저쓰레드를 기다려야 하기 때문에 반응성이 떨어집니다. one-to-one 모델은 리소스 효율성이 떨어지지만, 반응성이 가장 높습니다.
Fearless Concurrency in Rust
멀티쓰레드 프로그램은 공유 자원에 대한 race condition 등 synchronization risk가 존재하는데, Rust는 이를 해결하기 위해 Ownership, Borrowing, Lifetime 개념을 이용하여 컴파일 타임에 안전성을 보장합니다. Rust는 쓰레드 간의 안전한 데이터 공유를 위해 Send
와 Sync
트레이트를 제공합니다.
-
Send
: 다른 쓰레드로 이동할 수 있는 타입을 나타내는 트레이트입니다.T: Send
는T
타입의 값이 다른 쓰레드로 이동할 수 있다는 것을 의미합니다.Send
를 만족시키지 않는 타입의 예로Rc<T>
가 있습니다.Rc<T>
는 reference count를 변경하기 때문에 다른 쓰레드로 이동할 수 없습니다. -
Sync
: 다른 쓰레드와 안전하게 공유할 수 있는 타입을 나타내는 트레이트입니다.T: Sync
는T
타입의 값이 다른 쓰레드와 안전하게 공유할 수 있다는 것을 의미합니다.Sync
를 만족시키지 않는 타입의 예로RefCell<T>
가 있습니다.RefCell<T>
는 Internal mutability로T
타입의 값을 변경할 수 있기 때문에 다른 쓰레드와 안전하게 공유할 수 없습니다.
Thread 생성
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Hello, world!");
});
handle.join().unwrap();
}
thread를 생성할 때는 위와 같이 closure를 인자로 넘겨주어야 합니다. thread::spawn
함수는 새로운 쓰레드를 생성하고, 해당 쓰레드에서 closure를 실행합니다. 이때 spawn
메소드의 리턴 타입은 JoinHandle
인데, 여기서 이용 가능한 join
메소드는 쓰레드가 종료될 때까지 기다립니다.
이때 자식 쓰레드와 메인 쓰레드를 포함한 각 쓰레드간의 실행 순서는 OS의 스케줄링에 의해 런타임에 결정되므로 정확한 순서를 예측할 수 없습니다.
만약 join()
메소드를 호출하지 않을 경우에는 main 쓰레드가 종료되면 자식 쓰레드도 종료되기 때문에 정상적으로 동작을 마치지 못할 수 있습니다.
move
키워드
자식 쓰레드가 메인 쓰레드의 변수를 참조할 때, 참조하는 변수의 수명이 자식 쓰레드가 실행되는 동안 유지될지 보장할 수 없습니다. 이때 move
키워드를 사용하여 클로저가 소유권을 가져가도록 하면, 클로저가 소유권을 가져가면서 변수의 수명을 보장할 수 있습니다.
use std::thread;
fn main() {
let x = 10;
let handle = thread::spawn(move || {
println!("x: {}", x);
});
handle.join().unwrap();
}
Message Passing
Rust 표준 라이브러리에서는 channel의 구현을 제공함으로써 message-sending concurrency를 달성합니다. 채널의 양 끝은 transmitter와 receiver로 나뉘며, transmitter는 메시지를 보내고 receiver는 메시지를 받습니다.
채널 생성
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel(); // 튜플 리턴 (transmitter, receiver)
let handle = thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
handle.join().unwrap();
}
mpsc::channel()
함수를 이용하여 채널을 생성하고, tx.send()
메소드를 이용하여 메시지를 보냅니다. rx.recv()
메소드를 이용하여 메시지를 받습니다.
mpsc는 multipe-producer, single-consumer의 약자입니다.
recv()
메소드는 메인 쓰레드의 실행을 중지하고 채널에서 메시지가 도착할 때까지 기다립니다. try_recv()
메소드를 이용하면 메인 쓰레드 실행을 중지하지 않으며, 대신 즉시 Result<T, E>
를 반환합니다.
값이 이동할 때, ownership 역시 같이 이동하게 되므로, 이미 보낸 변수에 실수로 접근하는 것을 방지할 수 있습니다.
명시적으로 recv()
메소드를 호출하지 않아도 iterator를 이용하여 채널에서 메시지를 받을 수 있습니다.
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
for received in rx { // 채널이 닫힐 때까지 반복
println!("Got: {}", received);
}
handle.join().unwrap();
}
clone을 이용한 다수의 transmitter
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
thread::spawn(move || {
let val = String::from("hello");
tx1.send(val).unwrap();
});
for received in rx {
println!("Got: {}", received);
}
}
위와 같이 tx.clone()
메소드를 이용하여 동일한 receiver에게 메시지를 보낼 수 있는 다수의 transmitter를 생성할 수 있습니다.
Shared Memory
Message passing과 비교하자면, single ownership 성격이 강한 message passing 방식과 달리 shared memory 방식은 여러 쓰레드가 동시에 접근할 수 있는 공유 메모리를 이용합니다. 이는 multiple ownership 개념과 유사합니다. 서로 다른 owner들을 관리할 필요가 있어서 복잡성이 올라갑니다.
Mutex 사용
공유 메모리 자원에 접근하기 전 Lock을 얻고, 사용이 끝나면 unlock하는 유명한 방식입니다. Rust 의 타입 시스템과 ownership rule 덕분에 lock과 unlock을 실수 없이 안전하게 사용할 수 있습니다. 단 deadlock과 같은 logical error는 방지할 수 없습니다.
use std::sync::{Mutex, Arc};
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
.lock()
메소드를 사용하면 LockResult
타입을 반환하는데, 이는 Result
타입의 variant에 해당합니다. 이 값은 Ok
variant에 MutexGuard
타입의 값을 갖고 있으므로, unwrap()
메소드를 이용하여 LockResult
타입을 MutexGuard
타입으로 변환합니다. MutexGuard
타입은 Deref
트레이트를 구현하는 Smart pointer의 일종이라 *
연산자를 이용하여 값을 가져올 수 있습니다. Drop
trait도 구현하고 있는데, 이는 MutexGuard
타입이 범위를 벗어나면 자동으로 unlock
을 호출합니다.
Mutex<T>
는 interior mutability를 지원하므로 MutexGuard
를 역참조할 때 mutable reference를 얻어와 Mutex
내부의 값을 변경할 수 있습니다.
Arc<T>
는 Atomic reference counter 타입으로, Thread-safety를 보장하지 못하는 Rc<T>
타입의 멀티쓰레드용 대체 타입입니다. Arc
타입은 clone
메소드를 이용하여 쓰레드 간에 소유권을 공유할 수 있습니다.
그렇다면 멀티쓰레딩 상황이 아닌 단일 쓰레드에서는 왜 Arc
를 사용하지 않는 걸까요? Arc
는 atomic operation을 구현하기 위해 다른 쓰레드의 자원 접근을 막는 과정에서 약간의 성능 overhead가 발생하기 때문입니다. 따라서 단일 쓰레드에서는 Rc
를 사용하는 것이 더 효율적입니다.
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
- 13. Rust - I/O Management
- 14. Rust - Process & Thread Management
- 15. Rust - Inter-Process Communication