Rust编程:从编写简易shell到并发编程基础
1. 用Rust编写shell程序
1.1 项目准备
我们将逐步构建一个shell程序,每次迭代添加新功能。首先,创建一个新项目并设置文件结构:
1. 创建新项目:
cargo new myshell && cd myshell
-
创建三个文件:
src/iter1.rs、src/iter2.rs和src/iter3.rs,分别存放三次迭代的代码。 -
在
Cargo.toml中添加以下内容:
[[bin]]
name = "iter1"
path = "src/iter1.rs"
[[bin]]
name = "iter2"
path = "src/iter2.rs"
[[bin]]
name = "iter3"
path = "src/iter3.rs"
这将为三次迭代分别构建独立的二进制文件。
1.2 第一次迭代:创建子进程执行命令
第一次迭代的目标是编写一个程序,从终端接受命令,并创建子进程执行这些命令。代码如下:
// src/iter1.rs
use std::io::Write;
use std::io::{stdin, stdout};
use std::process::Command;
fn main() {
loop {
print!("$ "); // <1>
stdout().flush().unwrap(); // <2>
let mut user_input = String::new(); // <3>
stdin()
.read_line(&mut user_input) // <4>
.expect("Unable to read user input");
let command_to_execute = user_input.trim(); // <5>
let mut child = Command::new(command_to_execute) // <6>
.spawn()
.expect("Unable to execute command");
child.wait().unwrap(); // <7>
}
}
代码解释:
1. 显示
$
提示符,提示用户输入命令。
2. 刷新
stdout
句柄,确保
$
提示符立即显示在终端上。
3. 创建一个缓冲区来保存用户输入的命令。
4. 逐行读取用户命令。
5. 从缓冲区中移除换行符(用户按下回车键提交命令时添加)。
6. 创建一个新的子进程,并将用户命令传递给子进程执行。
7. 等待子进程完成执行后,再接受额外的用户输入。
运行程序:
cargo run --bin iter1
在
$
提示符下输入无参数的命令,如
ls
、
ps
或
du
,将看到命令执行的输出显示在终端上。按
Ctrl + C
退出程序。不过,此程序在命令后输入参数或标志时会失败。
1.3 第二次迭代:支持命令参数
第二次迭代使用
args()
方法添加对命令参数的支持:
// src/iter2.rs
// Module imports not shown here
fn main() {
loop {
print!("$ ");
stdout().flush().unwrap();
let mut user_input = String::new();
stdin()
.read_line(&mut user_input)
.expect("Unable to read user input");
let command_to_execute = user_input.trim();
let command_args: Vec<&str> =
command_to_execute.split_whitespace().
collect(); // <1>
let mut child = Command::new(command_args[0]) // <2>
.args(&command_args[1..]) // <3>
.spawn()
.expect("Unable to execute command");
child.wait().unwrap();
}
}
代码解释:
1. 将用户输入按空格分割,并将结果存储在
Vec
中。
2.
Vec
的第一个元素对应命令,创建子进程执行此命令。
3. 将
Vec
中从第二个元素开始的列表作为参数传递给子进程。
运行程序:
cargo run --bin iter2
在按下回车键之前输入命令并传递参数,例如:
-
ls –lah
-
ps -ef
-
cat a.txt
(假设
a.txt
是项目根文件夹中包含某些内容的现有文件)
1.4 第三次迭代:支持自然语言命令
第三次迭代实现对自然语言命令的支持,例如用
show files
代替
ls
。代码如下:
// src/iter3.rs
use std::io::Write;
use std::io::{stdin, stdout};
use std::io::{Error, ErrorKind};
use std::process::Command;
fn main() {
loop {
print!("$ ");
stdout().flush().unwrap();
let mut user_input = String::new();
stdin()
.read_line(&mut user_input)
.expect("Unable to read user input");
let command_to_execute = user_input.trim();
let command_args: Vec<&str> = command_to_execute.split_whitespace().collect();
if command_args.len() > 0 {
let child = match command_args[0] {
"show" if command_args.len() > 1 => match command_args[1] {
"files" => Command::new("ls").args(&command_args[2..]).spawn(),
"process" => Command::new("ps").args(&command_args[2..]).spawn(),
_ => Err(Error::new(
ErrorKind::InvalidInput,
"please enter valid command",
)),
},
"show" if command_args.len() == 1 => Err(Error::new(
ErrorKind::InvalidInput,
"please enter valid command",
)),
"quit" => std::process::exit(0),
_ => Command::new(command_args[0]).args(&command_args[1..]).spawn(),
};
match child {
Ok(mut child) => {
if child.wait().unwrap().success() {
} else {
println!("\n{}", "Child process failed")
}
}
Err(e) => match e.kind() {
ErrorKind::InvalidInput => eprintln!(
"Sorry, show command only supports following options: files , process "
),
_ => eprintln!("Please enter a valid command"),
},
}
}
}
}
运行程序:
cargo run --bin iter3
在
$
提示符下测试以下命令:
-
show files
-
show process
-
du
此迭代还添加了错误处理,处理以下错误情况:
- 用户不输入命令直接按回车键。
- 用户输入
show
命令但无参数(文件或进程)。
- 用户输入
show
命令但参数无效。
- 用户输入有效的Unix命令,但程序不支持(例如管道或重定向)。
1.5 总结与扩展
我们已经实现了一个基本的shell程序,能够识别自然语言命令,并处理了一些错误情况。作为练习,可以进行以下扩展:
- 添加对管道运算符分隔的命令链的支持,如
ps | grep sys
。
- 添加对重定向(如
>
运算符)的支持,将进程执行的输出转移到文件中。
- 将命令行解析逻辑移到单独的分词器模块中。
2. 并发编程基础
2.1 并发编程的价值
现代程序需要快速做出决策或在短时间内处理大量数据,许多用例无法通过顺序执行实现。例如,自动驾驶汽车需要同时处理来自各种传感器的输入、规划车辆路径并向执行器发送指令;网页浏览器需要同时处理用户输入和逐步渲染网页;网站需要处理多个用户的并发请求;网络爬虫需要同时访问数千个网站。
此外,单核CPU时钟速度已接近实际上限,需要增加CPU核心和处理器,这推动了软件对并发执行的需求,以充分利用额外的CPU核心。
2.2 并发与并行的区别
2.2.1 顺序执行
假设一个进程有两个任务A和B,任务A有三个子任务A1、A2和A3,任务B有两个子任务B1和B2,所有任务按顺序执行。如果任务A2需要等待外部网络、用户输入或系统资源,那么A2之后的所有任务都将被阻塞,直到A2完成,这不是CPU的有效利用方式,会导致进程中所有计划任务的完成延迟。
2.2.2 并发执行
现代应用程序通常是并发的,有多个执行线程同时运行。在并发模型中,进程交错执行任务,交替执行任务A和任务B,直到两者都完成。即使A2被阻塞,其他子任务仍可继续进行。每个子任务可以安排在单独的执行线程上,这些线程可以在单个处理器上运行,也可以跨多个处理器核心调度。并发是关于顺序无关的计算,编写适应顺序无关计算的程序比编写顺序程序更具挑战性。
2.2.3 并行执行
并行执行是并发执行模型的一种变体,进程在单独的CPU处理器或核心上真正并行地执行任务A和任务B。这假设软件的编写方式允许这种并行执行,并且任务A和任务B之间没有依赖关系,不会导致执行停滞或数据损坏。并行计算是一个广义术语,可以通过单台机器的多核或多处理器实现,也可以通过不同计算机的集群协同执行一组任务。
2.2.4 何时使用并发与并行执行
- 计算密集型程序 :涉及大量计算,如图形、气象或基因组处理,这类程序大部分时间使用CPU周期,使用更好更快的CPU会受益。
- I/O密集型程序 :大部分处理涉及与输入/输出设备(如网络套接字、文件系统和其他设备)通信,使用更快的I/O子系统(如磁盘或网络访问)会受益。
一般来说,并行执行(真正的并行性)更适合提高计算密集型用例中程序的吞吐量,而并发处理(或伪并行性)适合提高I/O密集型用例中的吞吐量和减少延迟。
2.3 多线程概念
Unix支持线程作为进程同时执行多个任务的机制。一个Unix进程启动时只有一个线程,即主执行线程,但可以创建额外的线程,这些线程可以在单处理器系统中并发执行,或在多处理器系统中并行执行。
每个线程都有自己的栈,用于存储局部变量和函数参数,还维护自己的寄存器状态,包括栈指针和程序计数器。一个进程中的所有线程共享相同的内存地址空间,意味着它们共享对数据段(初始化数据、未初始化数据和堆)的访问,也共享相同的程序代码(进程指令)。
在多线程进程中,多个线程可以同时执行同一个程序的不同部分(如不同函数),或在不同线程中调用同一个函数(处理不同的数据集)。但一个函数要能被多个线程同时调用,需要是线程安全的。使函数线程安全的方法包括:避免在函数中使用全局或静态变量、使用互斥锁限制函数一次只能由一个线程使用、使用互斥锁同步对共享数据的使用。
线程和进程模型各有优缺点:
| 对比项 | 线程 | 进程 |
| ---- | ---- | ---- |
| 数据共享 | 同一进程空间内,数据共享容易 | 数据共享复杂 |
| 资源共享 | 共享进程的公共资源,如文件描述符和用户/组ID | 资源独立 |
| 创建速度 | 线程创建速度快 | 进程创建速度慢 |
| 上下文切换 | 由于共享内存空间,CPU上下文切换快 | 上下文切换慢 |
| 复杂性 | 共享函数需线程安全,访问共享全局数据需同步,一个线程的缺陷可能影响其他线程或整个进程,代码执行顺序无保证,可能导致数据竞争、死锁或难以重现的错误 | 相对独立,一个进程的问题一般不会影响其他进程 |
2.4 多线程的内存布局
所有线程都在进程内存空间内分配内存。默认情况下,主线程有自己的栈,创建额外线程时也会为其分配自己的栈。进程的全局和静态变量可被所有线程访问,每个线程还可以将堆上创建的内存指针传递给其他线程,从而实现共享并发模型。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef thread fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
P1(进程P1):::process --> MT(主线程):::thread
P1 --> T1(线程1):::thread
P1 --> T2(线程2):::thread
P1 --> T3(线程3):::thread
MT --> MST(主线程栈):::thread
T1 --> T1ST(线程1栈):::thread
T2 --> T2ST(线程2栈):::thread
T3 --> T3ST(线程3栈):::thread
P1 --> DS(数据段):::process
P1 --> PC(程序代码):::process
MST --> DS
T1ST --> DS
T2ST --> DS
T3ST --> DS
MST --> PC
T1ST --> PC
T2ST --> PC
T3ST --> PC
通过学习并发编程基础,我们了解了如何创建子进程、与子进程的标准输入和标准输出交互、执行带参数的命令、设置和清除环境变量、处理进程错误和外部信号,以及编写一个能执行标准Unix命令并接受自然语言命令的shell程序。同时,我们也掌握了并发和并行的概念、多线程的实现和内存布局。为了进一步巩固学习,建议编写代码完成上述shell程序的扩展练习。
2.5 线程的创建与配置
在 Rust 中,可以使用
std::thread
模块来创建和管理线程。下面是一个简单的线程创建示例:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
// 线程执行的代码
println!("This is a new thread!");
});
// 等待新线程执行完毕
handle.join().unwrap();
println!("Main thread is done.");
}
代码解释:
1.
thread::spawn
函数用于创建一个新线程,它接受一个闭包作为参数,闭包中的代码将在新线程中执行。
2.
spawn
函数返回一个
JoinHandle
类型的句柄,用于等待新线程执行完毕。
3.
handle.join().unwrap()
会阻塞主线程,直到新线程执行完毕。
2.6 线程中的错误处理
在多线程程序中,错误处理尤为重要。当一个线程出现错误时,我们需要确保不会影响其他线程的正常运行。下面是一个包含错误处理的线程示例:
use std::thread;
use std::io::{Error, ErrorKind};
fn main() {
let handle = thread::spawn(|| {
let result = do_something_that_might_fail();
if let Err(e) = result {
eprintln!("Error in thread: {}", e);
}
});
handle.join().unwrap();
println!("Main thread is done.");
}
fn do_something_that_might_fail() -> Result<(), Error> {
// 模拟一个可能失败的操作
Err(Error::new(ErrorKind::Other, "Something went wrong!"))
}
代码解释:
1.
do_something_that_might_fail
函数返回一个
Result
类型,用于表示操作是否成功。
2. 在新线程中,如果操作失败,会打印错误信息。
3. 主线程等待新线程执行完毕,确保错误被正确处理。
2.7 线程间的消息传递
线程间的消息传递是并发编程中的重要概念。在 Rust 中,可以使用
std::sync::mpsc
模块来实现多生产者单消费者(MPSC)的消息传递。下面是一个简单的示例:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
// 发送消息
tx.send("Hello from the new thread!").unwrap();
});
// 接收消息
let msg = rx.recv().unwrap();
println!("Received: {}", msg);
handle.join().unwrap();
}
代码解释:
1.
mpsc::channel
函数创建一个通道,返回一个发送端
tx
和一个接收端
rx
。
2. 在新线程中,使用
tx.send
函数发送消息。
3. 在主线程中,使用
rx.recv
函数接收消息。
2.8 共享状态下的并发
在多线程程序中,多个线程可能会同时访问和修改共享状态,这可能会导致数据竞争和其他并发问题。在 Rust 中,可以使用
std::sync
模块中的同步原语(如
Mutex
、
RwLock
等)来确保线程安全。下面是一个使用
Mutex
的示例:
use std::thread;
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());
}
代码解释:
1.
Arc
是原子引用计数类型,用于在多个线程之间共享数据。
2.
Mutex
是互斥锁,用于确保同一时间只有一个线程可以访问共享数据。
3.
counter.lock().unwrap()
用于获取互斥锁,修改共享数据后会自动释放锁。
2.9 定时器暂停线程执行
在 Rust 中,可以使用
std::thread::sleep
函数来暂停线程的执行。下面是一个简单的示例:
use std::thread;
use std::time::Duration;
fn main() {
println!("Going to sleep for 2 seconds...");
thread::sleep(Duration::from_secs(2));
println!("Woke up after 2 seconds!");
}
代码解释:
1.
Duration::from_secs(2)
创建一个 2 秒的时间间隔。
2.
thread::sleep
函数会暂停当前线程的执行,直到指定的时间间隔过去。
2.10 总结与展望
通过学习并发编程,我们掌握了 Rust 中线程的创建、配置、错误处理、消息传递、共享状态并发和定时器的使用。并发编程可以让程序更高效地利用系统资源,处理多个任务。但同时,并发编程也带来了一些挑战,如数据竞争、死锁等。因此,在编写并发程序时,需要仔细设计和使用同步原语来确保线程安全。
为了进一步提升并发编程能力,建议进行以下练习:
1. 实现一个多线程的网络爬虫,使用消息传递机制协调不同线程的工作。
2. 编写一个多线程的文件处理程序,使用共享状态并发来提高处理效率。
3. 尝试使用不同的同步原语(如
RwLock
)来优化并发程序的性能。
通过不断实践和学习,我们可以更好地掌握并发编程的技巧,编写出高效、安全的并发程序。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef thread fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A(主线程):::thread --> B(创建新线程):::thread
B --> C(发送消息):::thread
A --> D(接收消息):::thread
B --> E(修改共享数据):::thread
A --> F(读取共享数据):::thread
B --> G(线程休眠):::thread
A --> H(等待线程结束):::thread
以上表格和流程图展示了并发编程中线程的主要操作流程,包括线程创建、消息传递、共享数据操作和线程休眠等。通过这些操作,我们可以构建出复杂的并发系统。
Rust Shell与并发编程详解
超级会员免费看
22

被折叠的 条评论
为什么被折叠?



