进程、任务与信号
本节目标
区分进程与 shell job,观察本练习启动的进程,并用保存的精确 PID 发送 TERM 信号后通过 wait 完成回收。
开始前状态
你已完成 LINUX-02:dev-lab 中存在规范的权限演示脚本,notes/linux-notes.md 精确包含前两节。本节不会把 PID、ps 输出或其他瞬时系统状态写入 checkpoint。
运行位置: 任意 Bash 工作目录;这一观察命令不写文件。预期输出: 当前 shell 的 PID 以及它能看到的任务;非交互式 shell 中 jobs 可能没有输出。失败处理: 若命令不可用,只阅读必要原理,不要为了观察去修改系统配置。
printf 'current shell PID: %s\n' "$$"
jobs
必要原理
进程是正在运行的程序实例,PID 是某一时刻标识进程的数字。shell job 是当前 shell 对管道或后台任务的管理记录;jobs 只显示当前 shell 的 job,不是系统进程清单,自动验收也不依赖交互式 job control。
命令以 & 启动后,$! 给出当前 shell 最近一次启动的后台任务 PID。必须立即保存它,之后的 ps -p "$practice_pid"、kill -TERM "$practice_pid" 和 wait "$practice_pid" 都只引用这个精确值。TERM 请求进程有序退出;wait 回收子进程并取得退出状态。
trap 为正常退出、命令失败或收到信号提供统一清理入口。本节只启动一个短时 sleep,只终止它保存下来的 PID,不依据进程名或搜索结果选择目标,也不使用不可恢复的强制终止方式。
动手完成
先运行进程练习。清理函数在目标仍存活时发送 TERM,并且只要保存过 PID 就执行 wait;因此正常、失败与中断路径都能回收本练习自己的子进程。ps 仅做即时观察,输出被隐藏,以保持成功 stdout 固定。
运行位置: 任意 Bash 工作目录;无需进入 dev-lab。预期输出: 精确为 process practice: cleaned。失败处理: 非零退出时,EXIT/信号 trap 仍会终止并 wait 已保存的 PID;不要扩大目标范围,检查本机 Bash、sleep 与 ps 是否可用后重试。
linux_process_practice() (
set -euo pipefail
practice_pid=''
cleanup_process() {
if test -n "$practice_pid"; then
if kill -0 "$practice_pid" 2>/dev/null; then
kill -TERM "$practice_pid" 2>/dev/null || true
fi
wait "$practice_pid" 2>/dev/null || true
practice_pid=''
fi
}
trap cleanup_process EXIT
trap 'cleanup_process; exit 129' HUP
trap 'cleanup_process; exit 130' INT
trap 'cleanup_process; exit 143' TERM
sleep 30 &
practice_pid=$!
ps -p "$practice_pid" >/dev/null
kill -TERM "$practice_pid"
wait "$practice_pid" 2>/dev/null || true
practice_pid=''
printf 'process practice: cleaned\n'
)
linux_process_practice
practice_status="$?"
unset -f linux_process_practice
[[ "$practice_status" -eq 0 ]] || exit "$practice_status"
练习成功后再推进规范笔记。迁移先验证物理与逻辑路径相同、完整 LINUX-02 路径集合、每个前置文件的精确内容与执行权限;只有全部匹配才在 notes/ 内创建草稿并原子替换笔记。
运行位置: 仓库中可访问上一节页面所用资源的位置,且 DEVLAB_DIR 指向完成 LINUX-02 的 lab。预期输出: 无输出,Linux 笔记只新增“进程、任务与信号”一节。失败处理: 任一检查失败都会保持 snapshot 不变并清理普通草稿;恢复规范 LINUX-02 状态后重试。
advance_linux_process_notes() (
set -euo pipefail
devlab_input="${DEVLAB_DIR:-}"
[[ -n "$devlab_input" ]] || { printf 'DEVLAB_DIR 必须是明确的练习目录。\n'; exit 1; }
[[ -d "$devlab_input" ]] || { printf 'DEVLAB_DIR 必须是真实目录。\n'; exit 1; }
devlab_logical="$(cd -- "$devlab_input" && pwd -L)" || exit 1
devlab_dir="$(cd -- "$devlab_input" && pwd -P)" || exit 1
[[ "$devlab_logical" == "$devlab_dir" && ! -L "$devlab_input" ]] || {
printf 'DEVLAB_DIR 不得经过符号链接。\n'
exit 1
}
[[ "$devlab_dir" != / ]] || { printf 'DEVLAB_DIR 必须是明确的练习目录。\n'; exit 1; }
if [[ -n "${HOME:-}" && -d "$HOME" ]]; then
home_dir="$(cd -- "$HOME" && pwd -P)" || exit 1
[[ "$devlab_dir" != "$home_dir" ]] || { printf 'DEVLAB_DIR 不得是用户主目录。\n'; exit 1; }
fi
for required_directory in config data notes output scripts; do
[[ -d "$devlab_dir/$required_directory" && ! -L "$devlab_dir/$required_directory" ]] || {
printf '前置目录必须是真实目录:%s\n' "$required_directory"
exit 1
}
done
expected_paths='README.md
config
config/bashrc.dev-lab
data
data/access.log
notes
notes/linux-notes.md
notes/shell-notes.md
output
output/summary.txt
scripts
scripts/permission-demo.sh
scripts/report.sh'
actual_paths="$(cd "$devlab_dir" && find . -mindepth 1 -print | sed 's#^\./##' | LC_ALL=C sort)"
[[ "$actual_paths" == "$expected_paths" ]] || { printf 'LINUX-02 路径集合不匹配。\n'; exit 1; }
require_exact() {
local relative_path="$1"
local expected_content="$2"
[[ -f "$devlab_dir/$relative_path" && ! -L "$devlab_dir/$relative_path" ]] \
&& cmp -s "$expected_content" "$devlab_dir/$relative_path" \
|| { printf '前置文件不匹配:%s\n' "$relative_path"; exit 1; }
}
has_any_execute_bit() {
local mode
mode="$(LC_ALL=C ls -ld "$1")"
mode="${mode%% *}"
[[ "$mode" == *[xstST]* ]]
}
require_exact 'README.md' <(printf '%s\n' \
'# dev-lab Shell checkpoint' \
'' \
'这是终端与 Shell 板块完成后的权威参考状态。它保留固定访问日志、确定性摘要、隔离 Bash 配置和只会写入明确输出文件的报告脚本。' \
'' \
'请在此目录的父目录运行 `check-shell.sh`;checker 只读取目标,不会 source 配置或执行报告脚本。')
require_exact 'config/bashrc.dev-lab' <(printf '%s\n' \
"export DEVLAB_MODE='practice'" \
'' \
'devlab-root() {' \
' printf '\''%s\n'\'' "${DEVLAB_DIR:?set DEVLAB_DIR before using devlab-root}"' \
'}' \
'' \
'devlab-summary() {' \
' bash "${DEVLAB_DIR:?set DEVLAB_DIR first}/scripts/report.sh" "$DEVLAB_DIR"' \
'}')
require_exact 'data/access.log' <(printf '%s\n' \
'2026-08-12T09:00:00Z GET / 200' \
'2026-08-12T09:00:01Z GET /docs/devenv/welcome 200' \
'2026-08-12T09:00:02Z GET /docs/devenv/module-route 200' \
'2026-08-12T09:00:03Z GET /missing 404' \
'2026-08-12T09:00:04Z POST /practice 200' \
'2026-08-12T09:00:05Z GET /old-link 404')
require_exact 'notes/shell-notes.md' <(printf '%s\n' \
'# Shell 练习笔记' \
'' \
'历史记录可能包含参数、路径或令牌;先检查再分享或清理。Tab 补全帮助确认命令和文件名,但不代替阅读实际路径。' \
'' \
'本 checkpoint 的 Bash 配置仅用于 `DEVLAB_DIR` 指定的隔离练习目录。')
require_exact 'output/summary.txt' <(printf '%s\n' \
'total requests: 6' \
'successful requests: 4' \
'not found: 2')
require_exact 'scripts/report.sh' <(printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'[[ "$#" -eq 1 ]] || { printf '\''usage: report.sh DEV_LAB_DIR\n'\'' >&2; exit 64; }' \
'devlab_dir="${1:?usage: report.sh DEV_LAB_DIR}"' \
'input="$devlab_dir/data/access.log"' \
'output="$devlab_dir/output/summary.txt"' \
'[[ -f "$input" ]] || { printf '\''missing input: %s\n'\'' "$input" >&2; exit 1; }' \
'' \
'total="$(wc -l < "$input" | tr -d '\'' '\'')"' \
'successful="$(grep -c '\'' 200$'\'' "$input" || true)"' \
'not_found="$(grep -c '\'' 404$'\'' "$input" || true)"' \
'' \
'{' \
' printf '\''total requests: %s\n'\'' "$total"' \
' printf '\''successful requests: %s\n'\'' "$successful"' \
' printf '\''not found: %s\n'\'' "$not_found"' \
'} > "$output"')
require_exact 'scripts/permission-demo.sh' <(printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'printf '\''permission demo: ok\n'\''')
require_exact 'notes/linux-notes.md' <(printf '%s\n' \
'# Linux 基础练习笔记' \
'' \
'## 文件系统' \
'- 系统目录只观察;所有练习写入明确的 dev-lab。' \
'- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。' \
'' \
'## 用户、用户组与权限' \
'- 先确认目标是非符号链接普通文件,再只增加用户执行位。')
[[ -x "$devlab_dir/scripts/report.sh" ]] || { printf 'report.sh 必须可执行。\n'; exit 1; }
[[ -x "$devlab_dir/scripts/permission-demo.sh" ]] || { printf 'permission-demo.sh 必须可执行。\n'; exit 1; }
bash -n "$devlab_dir/scripts/report.sh"
bash -n "$devlab_dir/scripts/permission-demo.sh"
bash -n "$devlab_dir/config/bashrc.dev-lab"
for relative_path in README.md config/bashrc.dev-lab data/access.log notes/shell-notes.md output/summary.txt notes/linux-notes.md; do
! has_any_execute_bit "$devlab_dir/$relative_path" || { printf '前置文件不得可执行:%s\n' "$relative_path"; exit 1; }
done
current_notes="$devlab_dir/notes/linux-notes.md"
notes_draft="$devlab_dir/notes/.linux-notes.md.next"
notes_dir="$devlab_dir/notes"
file_identity() {
local identity
if identity="$(LC_ALL=C stat -L -c '%d:%i' "$1" 2>/dev/null)"; then :; else
identity="$(LC_ALL=C stat -L -f '%d:%i' "$1")" || return 1
fi
printf '%s' "$identity"
}
fd_matches_identity() {
local fd_identity
fd_identity="$(file_identity "$1")" || return 1
[[ "$fd_identity" == "$2" || "$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)" == Darwin \
&& "${fd_identity##*:}" == "${2##*:}" ]]
}
remove_if_owned() {
local owned_path="$1" owned_identity="$2" owned_name="${1##*/}"
[[ -n "$owned_identity" ]] || return 0
(
cd -P -- "$notes_dir" || exit 1
cleanup_owned_boundary="$owned_name"
[[ "$(file_identity .)" == "$notes_dir_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || exit 1
[[ ! -e "./$owned_name" && ! -L "./$owned_name" ]] && exit 0
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name" 2>/dev/null)" == "$owned_identity" ]] || exit 1
/bin/rm -f -- "./$owned_name"
)
}
create_owned_file() {
local owned_path="$1" parent_path="$2" parent_identity="$3" mode="$4" identity_variable="$5"
local owned_name="${owned_path##*/}" caller_directory="$PWD" created_identity=''
cd -P -- "$parent_path" || return 1
[[ "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || return 1
set -o noclobber
exec 6> "./$owned_name" || { set +o noclobber; return 1; }
set +o noclobber
created_identity="$(file_identity "./$owned_name")" || { exec 6>&-; return 1; }
printf -v "$identity_variable" '%s' "$created_identity"
owned_post_create_boundary="$owned_name"
fd_matches_identity /dev/fd/6 "$created_identity" || { exec 6>&-; return 1; }
/bin/cat >&6 || { exec 6>&-; return 1; }
owned_post_write_boundary="$owned_name"
/bin/chmod "$mode" /dev/fd/6 || { exec 6>&-; return 1; }
owned_post_mode_boundary="$owned_name"
fd_matches_identity /dev/fd/6 "$created_identity" || { exec 6>&-; return 1; }
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$created_identity" \
&& "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || { exec 6>&-; return 1; }
exec 6>&-
cd -P -- "$caller_directory" || return 1
}
publish_owned_file() {
local owned_path="$1" owned_identity="$2" final_path="$3" parent_identity="$4" expected_final_identity="$5"
local parent_path="${owned_path%/*}" owned_name="${owned_path##*/}" final_name="${final_path##*/}"
(
cd -P -- "$parent_path" || exit 1
publication_boundary="$owned_name->$final_name"
[[ "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || exit 1
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$owned_identity" ]] || exit 1
[[ -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$expected_final_identity" ]] || exit 1
publication_commit_boundary="$owned_name->$final_name"
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$owned_identity" \
&& -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$expected_final_identity" ]] || exit 1
if [[ "$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)" == Darwin ]]; then
/bin/mv -f -h -- "./$owned_name" "./$final_name" || exit 1
else
/bin/mv -f -T -- "./$owned_name" "./$final_name" || exit 1
fi
[[ ! -e "./$owned_name" && ! -L "./$owned_name" \
&& -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$owned_identity" \
&& "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]]
)
}
devlab_identity="$(file_identity "$devlab_dir")" || exit 1
notes_dir_identity="$(file_identity "$notes_dir")" || exit 1
current_notes_identity="$(file_identity "$current_notes")" || exit 1
[[ ! -e "$notes_draft" && ! -L "$notes_draft" ]] || { printf '笔记草稿路径已存在,停止并检查。\n'; exit 1; }
notes_draft_identity=''
cleanup_linux_process_draft() {
local status="$?"
trap - EXIT
remove_if_owned "$notes_draft" "$notes_draft_identity" || exit 1
exit "$status"
}
trap cleanup_linux_process_draft EXIT
create_owned_file "$notes_draft" "$notes_dir" "$notes_dir_identity" u=rw,go=r notes_draft_identity <<'EXPECTED'
# Linux 基础练习笔记
## 文件系统
- 系统目录只观察;所有练习写入明确的 dev-lab。
- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。
## 用户、用户组与权限
- 先确认目标是非符号链接普通文件,再只增加用户执行位。
## 进程、任务与信号
- 只终止本练习刚启动并保存 PID 的进程,随后 wait 完成回收。
EXPECTED
cmp -s <(printf '%s\n' \
'# Linux 基础练习笔记' \
'' \
'## 文件系统' \
'- 系统目录只观察;所有练习写入明确的 dev-lab。' \
'- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。' \
'' \
'## 用户、用户组与权限' \
'- 先确认目标是非符号链接普通文件,再只增加用户执行位。' \
'' \
'## 进程、任务与信号' \
'- 只终止本练习刚启动并保存 PID 的进程,随后 wait 完成回收。') \
"$notes_draft" || exit 1
[[ "$(file_identity "$notes_dir")" == "$notes_dir_identity" \
&& "$(file_identity "$notes_draft")" == "$notes_draft_identity" ]] || exit 1
publish_owned_file "$notes_draft" "$notes_draft_identity" "$current_notes" "$notes_dir_identity" "$current_notes_identity"
[[ "$(file_identity "$current_notes")" == "$notes_draft_identity" ]] || exit 1
)
advance_linux_process_notes
process_transition_status="$?"
unset -f advance_linux_process_notes
[[ "$process_transition_status" -eq 0 ]]
观察结果
成功输出不包含 PID,checkpoint 只新增确定性的第三节。ps 与 jobs 反映运行时状态,适合现场观察,不适合作为可复现快照内容。
常见问题
为什么不能根据名称结束进程? 名称可能对应多个实例,也可能匹配到不属于练习的进程。保存 $! 能把操作绑定到刚启动的唯一子进程。
发送 TERM 后为什么还要 wait? 信号只提出退出请求;wait 才让父 shell 回收子进程并确认它已经结束。清理函数必须覆盖正常、失败与中断路径。
完成检查
- 能解释 process、job 与 PID 的关系;
- 知道
$!必须在启动后台任务后立即保存; - 只向保存的 PID 发送 TERM;
- 正常、失败或中断时都通过 trap 进入清理,并执行
wait; - 成功 stdout 精确为
process practice: cleaned; - Linux 笔记只原子新增第三节,不保存瞬时系统状态。
下一步
下一节学习命令来源、版本与本地包状态;必做练习只模拟或查看信息,不进行真实安装。