Это продолжение серии. Если вы не читали первую часть, рекомендую начать с неё: https://habr.com/ru/articles/1000766/


Что было в прошлый раз

В первой статье я рассказал про базовый shell: как работают fork(), exec(), wait(), и почему cd должна быть встроенной командой. Получился простой shell, который умел запускать программы и перенаправлять вывод.


Что добавилось

За последние недели реализовал четыре крупных фичи:

  • Pipes — соединять программы (ls | grep | wc)

  • Background процессы — запуск в фоне (sleep 100 &)

  • Job Control — управление задачами (jobs, fg, bg)

  • Сигналы — обработка Ctrl+C и Ctrl+Z

Давайте разберём каждую.


1. Pipes — самая интересная часть!

Что работает:

myshell> ls | grep txt
file1.txt
file2.txt

myshell> cat file.txt | grep "hello" | wc -l
5

Как это работает?

Представьте конвейер на заводе. Первый рабочий делает деталь и передаёт её второму через ленту.

[ls процесс]  →  [труба в памяти]  →  [grep процесс]
 stdout              buffer              stdin

Код (упрощённо):

int pipefd[2];
pipe(pipefd);  // создали трубу

// Запускаем ls:
if (fork() == 0) {
    dup2(pipefd[1], STDOUT_FILENO);  // stdout → труба
    close(pipefd[0]);
    exec("ls");
}

// Запускаем grep:
if (fork() == 0) {
    dup2(pipefd[0], STDIN_FILENO);  // stdin ← труба
    close(pipefd[1]);
    exec("grep");
}

// ВАЖНО: родитель закрывает оба конца!
close(pipefd[0]);
close(pipefd[1]);

Баг, который поймал:

Забыл закрыть pipefd в родительском процессе. Результат: grep зависал, ждал конца данных, но труба оставалась открытой!

Урок: Родитель обязан закрыть оба конца трубы, иначе программы не поймут, что данные закончились.


2. Background процессы — работа в фоне

Что работает:

myshell> sleep 100 &
[1] 12345
myshell>  # сразу получили приглашение!

Shell не ждёт завершения, можно работать дальше.

Как это работает?

bool background = (last_token == "&");

pid_t pid = fork();
if (pid == 0) {
    exec(command);
} else {
    if (background) {
        add_job(pid, command);  // добавили в список задач
        printf("[1] %d\n", pid);
        // НЕ вызываем wait()!
    } else {
        foreground_pid = pid;
        wait(NULL);
    }
}

Проблема: зомби-процессы!

Когда фоновый процесс завершается, он становится зомби. Решение — обработчик SIGCHLD:

signal(SIGCHLD, sigchld_handler);

void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

3. Job Control — управление задачами

Это было самое сложное! Нужно отслеживать все фоновые процессы, их состояния, и уметь управлять ими.

Что работает:

myshell> sleep 100 &
[1] 12345
myshell> sleep 200 &
[2] 12346

myshell> jobs
[1]  Running    sleep 100
[2]  Running    sleep 200

myshell> fg %1
sleep 100
^Z
[1]+ Stopped    sleep 100

myshell> jobs
[1]  Stopped    sleep 100
[2]  Running    sleep 200

myshell> bg %1
[1]+ sleep 100 &

myshell> jobs
[1]  Running    sleep 100
[2]  Running    sleep 200

Как это работает?

Структура задачи:

struct Job {
    int job_id;
    pid_t pid;
    std::string command;
    enum { RUNNING, STOPPED, DONE } state;
};

std::vector<Job> job_list;

Команда fg (вернуть на передний план):

void cmd_fg(int job_id) {
    Job* job = find_job(job_id);
    
    kill(job->pid, SIGCONT);  // продолжить выполнение
    foreground_pid = job->pid;
    
    waitpid(job->pid, nullptr, 0);  // ждём завершения
    
    job->state = Job::DONE;
}

Команда bg (продолжить в фоне):

void cmd_bg(int job_id) {
    Job* job = find_job(job_id);
    
    kill(job->pid, SIGCONT);  // продолжить
    job->state = Job::RUNNING;
}

Инсайт:

Процессы могут быть в трёх состояниях:

  • RUNNING — работает

  • STOPPED — остановлен (Ctrl+Z)

  • DONE — завершён

Shell должен отслеживать переходы между этими состояниями через сигналы!


4. Сигналы — Ctrl+C и Ctrl+Z

Задача:

myshell> sleep 100
^C  # Ctrl+C должен убить sleep, но НЕ shell!
myshell>

Как это работает?

pid_t foreground_pid = -1;

signal(SIGINT, sigint_handler);  // Ctrl+C

void sigint_handler(int sig) {
    if (foreground_pid > 0) {
        kill(foreground_pid, SIGINT);
        foreground_pid = -1;
    }
    printf("\nmyshell> ");
}

Ctrl+Z (остановка процесса):

signal(SIGTSTP, sigtstp_handler);

void sigtstp_handler(int sig) {
    if (foreground_pid > 0) {
        kill(foreground_pid, SIGSTOP);
        
        // Добавляем в jobs со статусом STOPPED
        Job* job = find_job_by_pid(foreground_pid);
        job->state = Job::STOPPED;
        
        foreground_pid = -1;
    }
}

Инсайт: Ctrl+C и Ctrl+Z — это сигналы от ядра, а не команды. Shell их перехватывает и решает, что делать.


Самая сложная часть всего проекта

Координация сигналов и состояний процессов.

Пример проблемы:

  1. Запускаю sleep 100

  2. Жму Ctrl+Z — процесс останавливается

  3. Вызываю bg %1 — процесс продолжается в фоне

  4. Процесс завершается — приходит SIGCHLD

  5. Нужно обновить состояние на DONE

Всё это должно работать согласованно. Одна ошибка — и shell начинает вести себя странно.


Что узнал

Pipes сложнее, чем кажутся

Жонглирование file descriptors. Забыл закрыть один — зависло. Закрыл не тот — broken pipe.

Сигналы — это асинхронность

Обработчик сигнала может сработать в любой момент. Нужно быть осторожным с глобальными переменными.

Job Control — это state machine

Процесс может быть в разных состояниях, переходы между ними управляются сигналами. Это целая машина состояний!

Bash сложнее, чем кажется

Раньше думал "bash — это просто программа". Теперь понимаю сколько там логики: управление задачами, сигналы, состояния, координация процессов.


GitHub

Весь код: GitHub

Буду рад фидбеку и pull request'ам!


P.S. Это учебный проект для изучения OS. Код далёк от production-ready bash, но каждая строка — это понимание системы, которой мы пользуемся каждый день.

Вопросы приветствуются!