시그널 다루기

커맨드라인 애플리케이션과 같은 프로세스는 운영체제가 보낸 시그널에 반응해야 합니다. 시그널의 가장 흔한 예시는 아마도, 일반적으로 프로세스를 종료시킬 때 쓰는 Ctrl+C일 것 입니다. 러스트 프로그램에서 시그널을 다루기 위해서는 시그널에 반응하는 방법뿐 아니라 시그널을 수신하는 방법에 대해서도 고민해 봐야 합니다.

운영체제 간 차이점

유닉스 시스템(리눅스, macOS, FreeBSD 등)에서 프로세스는 시그널을 받을 수 있습니다. 프로세스는 시그널을 받아서 프로그램이 정의한 방식으로 시그널을 처리하는 기본적인 방법(OS가 제공)으로 시그널에 반응할 수도 있고, 시그널을 통째로 무시할 수도 있습니다.

윈도우즈에는 시그널이 없습니다. 대신 콘솔 핸들러를 이용하여 이벤트가 발생했을 때 실행되는 콜백을 정의할 수 있습니다. 또한 윈도우즈에는 0으로 나누기, 잘못된 접근, 스택오버플로우 등 모든 종류의 시스템 예외를 처리할 수 있는 구조적 예외 처리도 있습니다.

Ctrl+C 다루기

ctrlc 크레이트는 이름 그대로의 일을 합니다. ctrlc는 다양한 플랫폼에 대해 사용자가 Ctrl+C을 눌렀을 때 프로그램이 반응할 수 있도록 만들어 줍니다. ctrlc의 주요 사용법은 아래와 같습니다:

use std::{thread, time::Duration};

fn main() {
    ctrlc::set_handler(move || {
        println!("received Ctrl+C!");
    })
    .expect("Error setting Ctrl-C handler");

    // 다음 코드는 실제로 동작하며, Ctrl-C로 인터럽트될 수 있다.
    // 이 예시에서는 몇 초간 기다린다.
    thread::sleep(Duration::from_secs(2));
}

물론 이렇게 하면 별 도움이 되지 않습니다. 메시지를 출력할 뿐 프로그램을 종료시키지는 않으니까요.

실제 프로그램의 경우, 시그널 핸들러에서 변수를 설정해 프로그램 곳곳에서 상태를 체크하는 것이 좋습니다. 예를 들어, 시그널 핸들러에서 Arc<AtomicBool> (스레드 간에 공유할 수 있는 불리언 타입) 변수를 설정하면, 루프(hot loop)를 돌거나 스레드를 대기할 때 그 값을 주기적으로 체크하면서 true가 되면 프로그램을 종료하도록 할 수 있습니다.

다른 시그널 다루기

ctrlc 크레이트는 Ctrl+C, 혹은 유닉스 시스템에서 SIGINT (“인터럽트” 시그널)라고 불리는 시그널만 다룰 수 있습니다. 더 많은 유닉스 시그널에 반응하기 위해서는 signal-hook를 사용해야 합니다. signal-hook의 설계는 이 블로그 글에 설명되어 있으며, 현재 가장 광범위한 커뮤니티 지원을 받는 라이브러리입니다.

여기 간단한 예시가 있습니다:

use signal_hook::{consts::SIGINT, iterator::Signals};
use std::{error::Error, thread, time::Duration};

fn main() -> Result<(), Box<dyn Error>> {
    let mut signals = Signals::new(&[SIGINT])?;

    thread::spawn(move || {
        for sig in signals.forever() {
            println!("Received signal {:?}", sig);
        }
    });

    // 다음 코드는 실제로 동작하며, Ctrl-C로 인터럽트될 수 있다.
    // 이 예시에서는 몇 초간 기다린다.
    thread::sleep(Duration::from_secs(2));

    Ok(())
}

채널 사용하기

변수를 설정하고, 프로그램이 그 변수를 체크하도록 만드는 대신 채널을 사용할 수 있습니다: 채널을 만들면 시그널을 수신할 때마다 시그널 핸들러가 채널로 값을 내보내 줍니다. 애플리케이션 코드에서는 한 채널과 다른 채널을 스레드 간의 동기화 지점으로 사용하게 됩니다. crossbeam-channel을 사용하면 아래와 같은 모습이 됩니다:

use std::time::Duration;
use crossbeam_channel::{bounded, tick, Receiver, select};
use anyhow::Result;

fn ctrl_channel() -> Result<Receiver<()>, ctrlc::Error> {
    let (sender, receiver) = bounded(100);
    ctrlc::set_handler(move || {
        let _ = sender.send(());
    })?;

    Ok(receiver)
}

fn main() -> Result<()> {
    let ctrl_c_events = ctrl_channel()?;
    let ticks = tick(Duration::from_secs(1));

    loop {
        select! {
            recv(ticks) -> _ => {
                println!("working!");
            }
            recv(ctrl_c_events) -> _ => {
                println!();
                println!("Goodbye!");
                break;
            }
        }
    }

    Ok(())
}

퓨쳐(futures)와 스트림 사용하기

tokio를 사용하고 있다면, 여러분은 이미 비동기 패턴과 이벤트 주도 설계를 적용하여 애플리케이션을 작성하고 있을 확률이 높습니다. 이때는 crossbeam의 채널을 직접 사용하지 않고 signal-hooktokio-support 기능을 사용할 수 있습니다. 이 기능을 이용하면 signal-hookSignals 타입에 대해 .into_async()를 호출하여 futures::Stream을 구현하는 새로운 타입을 얻을 수 있습니다.

첫 Ctrl+C 시그널을 처리하는 도중 또 다른 Ctrl+C 시그널을 수신했을 때

사용자가 Ctrl+C를 누르면 여러분의 프로그램은 몇 초 뒤 종료되거나 진행 상황을 알려줄 것입니다. 만약 그렇지 않으면 사용자는 Ctrl+C을 한 번 더 누를 것입니다. 이때 일반적인 동작은 애플리케이션을 즉시 종료하는 것입니다.