跳转至

SSH

sshSecure Shell 的缩写,是一种网络协议,也是一套工具,主要用于在不安全的网络中安全地访问远程计算机。

在vscode中,你可以通过“远程资源管理器”插件进行连接,你本地机器上关于ssh的config的目录在C:\Users\name\.ssh\config或者C:\ProgramData\ssh\ssh_config(windows)下;ubuntu则是在~/.ssh/config/etc/ssh/ssh_config下,其中~/.ssh/config是用户的ssh配置目录,而/etc/ssh/ssh_config是系统的ssh配置目录。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Host NewLittleDog
  HostName 192.168.154.75
  User pi
Host NUC
  # HostName 192.168.3.100
    HostName 192.168.25.121
    User robocon
    ForwardX11 yes
    ForwardX11Trusted yes
    IdentityFile C:\Users\xixiboliya\.ssh\id_rsa 
注意 SSH config 没有提供密码配置,因为将密码存储在明文文件中是不安全的做法,请使用密钥登录。

X11 转发:远程程序为什么能在本机弹窗

一个常见于通过 SSH 转发远程主机图形界面(例如 rvizrqtxeyes)的错误:

1
2
3
4
5
6
qt.qpa.xcb: could not connect to display 
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found.
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.


Available platform plugins are:...   

X11协议

这部分主要帮助理解 SSH 图形转发的原理。实际调车时,如果可以直接接显示器、无线 HDMI、NoMachine、VNC 或 Web 可视化工具,往往会比 X11 转发稳定。

先把两个容易反直觉的名词说清楚:

  • X Server:负责真正显示窗口、接收键盘鼠标输入的一端。它通常运行在你面前的电脑上,也就是本地主机 B。
  • X Client:需要显示图形界面的程序,例如 rvizrqtxterm。它可以运行在远端服务器 A 上。

这和我们平时理解的“服务器/客户端”容易反过来。因为在 X11 语境里,谁控制屏幕键鼠,谁就是 X Server。远端运行的图形程序只是向这个显示服务发请求,所以它叫 X Client。

为了方便说明,后面统一使用下面的角色:

  • 本地主机 B:你正在操作的电脑,负责显示窗口;
  • 远端服务器 A:通过 SSH 登录的机器人、NUC、树莓派或服务器,负责运行图形程序;
  • 本地 ssh 客户端:你在 B 上运行的 SSH 程序;
  • 远端 sshd 服务端:A 上接收 SSH 连接的服务。

不使用 SSH 时,X11 怎么找显示器

X 程序通过 DISPLAY 环境变量知道应该把窗口画到哪里。它的常见格式是:

1
hostname:display.screen

例如:

  • :0:连接本机的 0 号显示;
  • localhost:0.0:连接本机 localhost 上的 0 号显示、0 号屏幕;
  • 192.168.1.10:0.0:连接 192.168.1.10 这台机器上的 0 号显示。

传统 TCP 模式下,X Server 的端口号通常是 6000 + display。例如 :0 对应 6000,:10 对应 6010。但现代 Linux 桌面经常使用 Unix socket,很多 X Server 也默认关闭 TCP 监听,所以不要把 X11 简单理解成“永远连 6000 端口”。

使用 SSH X11 转发时,真正发生了什么

当你在本地主机 B 上运行:

1
ssh -X user@server_A

或者在 ~/.ssh/config 里写了:

1
2
3
4
Host server_A
    HostName 192.168.x.x
    User your_user
    ForwardX11 yes

SSH 会做几件事:

  1. 本地主机 B 上必须先有一个可用的 X Server。Linux 桌面通常已经有;Windows 常见选择是 VcXsrv、Xming、MobaXterm 自带 X Server 或 WSLg;macOS 常见选择是 XQuartz。
  2. 本地 ssh 客户端请求远端 sshd 开启 X11 forwarding。
  3. 远端 sshd 在服务器 A 上创建一个“代理 X Server”,并把远端 shell 里的 DISPLAY 自动设置成类似 localhost:10.0 的值。
  4. 你在服务器 A 上启动 rvizrqtxeyes 这类 X Client 时,它们会连接 DISPLAY=localhost:10.0
  5. 这个连接并不会真的显示在服务器 A 的屏幕上,而是被远端 sshd 收下,通过 SSH 加密通道发回本地主机 B。
  6. 本地 ssh 客户端再把数据交给本地真实的 X Server,窗口最终显示在 B 的屏幕上。

键盘鼠标事件也会沿着同一条链路反向传回远端程序,所以这个过程是双向的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
远端图形程序(X Client)
        |
        v
远端 DISPLAY=localhost:10.0 代理入口
        |
        v
远端 sshd == SSH 加密通道 == 本地 ssh
        |
        v
本地 X Server
        |
        v
本机屏幕、键盘、鼠标

这里最关键的一点是:SSH 成功开启 X11 转发后,远端的 DISPLAY 应该由 SSH 自动设置,不应该手动改成 localhost:0.0

如果你在远端手动执行:

1
export DISPLAY=localhost:0.0

它的意思通常会变成“让远端程序去找远端服务器 A 自己的 0 号显示”,而不是“显示到本地主机 B”。这正是很多教程容易讲乱的地方。

为什么会出现 localhost:10.0

localhost:10.0 里的 10 不是随便来的。OpenSSH 服务端有一个配置项叫 X11DisplayOffset,默认值通常是 10。它表示 sshd 给 X11 转发选择显示编号时,从 10 开始找,避免和机器上真正存在的 :0:1 等本地显示冲突。

所以看到远端 echo $DISPLAY 输出:

1
localhost:10.0

通常是好事,说明 SSH 已经帮你创建了代理入口。这里的 localhost 指的是远端服务器 A 自己的回环地址,不是本地主机 B。

推荐配置

远端服务器 A 的 /etc/ssh/sshd_config 需要允许 X11 转发:

1
sudo nano /etc/ssh/sshd_config

建议配置:

1
2
3
X11Forwarding yes
X11DisplayOffset 10
X11UseLocalhost yes

修改后重启 SSH 服务:

1
sudo systemctl restart ssh

本地主机 B 的 SSH 配置可以写成:

1
2
3
4
Host alias_A
    HostName ip_A
    User usr_A
    ForwardX11 yes

如果 rviz、Qt 或 OpenGL 程序在普通 -X 下受限,可以改用可信转发:

1
2
3
4
5
Host alias_A
    HostName ip_A
    User usr_A
    ForwardX11 yes
    ForwardX11Trusted yes

等价命令是:

1
2
ssh -X alias_A   # 普通 X11 转发
ssh -Y alias_A   # 可信 X11 转发,权限更大,风险也更高

ForwardAgent yes 是 SSH agent 转发,和 X11 转发不是一回事。除非你明确需要在远端继续用本地私钥跳转到其他机器,否则不要因为图形转发而顺手打开它。

Windows 本地主机要额外注意

如果本地主机 B 是 Windows,并且你在 PowerShell 里直接运行 ssh -X,需要先启动一个 Windows 侧的 X Server,例如 VcXsrv。很多情况下还需要让本地 SSH 客户端知道真实 X Server 在哪里:

1
2
$env:DISPLAY = "localhost:0.0"
ssh -Y alias_A

这一步设置的是本地主机 B 上的环境变量,目的是让本地 ssh 客户端能连接本地 X Server。登录成功后,远端服务器 A 里的 DISPLAY 仍然应该由 SSH 自动设置成类似 localhost:10.0,不要在远端再手动改回 localhost:0.0

快速检查流程

连接远端后先看:

1
echo $DISPLAY

正常情况下应看到类似:

1
localhost:10.0

然后用一个小程序测试:

1
2
sudo apt install xauth x11-apps
xeyes

如果 xeyes 能在本机弹出窗口,再测试 rqtrviz。如果 echo $DISPLAY 是空的,说明这次 SSH 会话没有成功开启 X11 转发。

常见问题怎么判断

现象 常见原因 检查方向
echo $DISPLAY 为空 SSH 没开 X11 转发 使用 ssh -X/-Y,或检查 ForwardX11 yes
X11 forwarding request failed 远端 sshd_config 不允许,或缺少 xauth 检查 X11Forwarding yes,安装 xauth
qt.qpa.xcb: could not connect to display 没有可用 DISPLAY,或本地 X Server 没启动 本地先启动 X Server,再重新 SSH
rviz 能启动但显示异常 普通 -X 权限受限或 OpenGL 问题 尝试 ssh -Y,或改用 VNC/NoMachine
sudo rviz 后打不开 sudo 改变了环境和 Xauthority 尽量不要用 sudo 跑 GUI;必须用时再单独处理权限

总结一下:X11 转发不是让远端程序直接连本机的 6000 端口,而是 SSH 在远端创建一个代理 DISPLAY,远端程序连这个代理,SSH 再把图形协议转回本地真实的 X Server。正常使用时,本地要有 X Server,远端要允许 X11 forwarding,而远端的 DISPLAY 由 SSH 自动设置。

参考资料:

Public Key & Private Key

(参考只用10分钟,一次性搞懂公钥和私钥 - 知乎)

上述只是一个简要介绍。实际上,当你在校园网内用浏览器访问服务器的 http(s)://<IP>:<port> 时,服务器在 TLS 握手阶段会向客户端发送其 X.509 数字证书(证书包含服务器的公钥与身份信息,并由某个 CA 签名)。客户端使用该证书验证服务器身份(校验签名链、有效期、主机名/IP 是否匹配等),并要求服务器通过对应的私钥对握手数据进行签名以证明私钥所有权。只有在验证通过后,双方才通过密钥交换协商出对称会话密钥并开始加密的 HTTP 通信。若你把队里服务器的证书或相应的 CA 根证书安装到了客户端信任存储中,就等于告诉客户端“信任这个证书/这个 CA”,这会影响上面的验证过程。

我们的操作系统已经预装了大量受信任的CA的根证书。如果服务器发送的证书是由这些CA之一颁发的,你的系统会自动验证其签名和有效性,你不需要额外做任何事情。

但如果这个证书是自签名(没有CA的签名)或者是由一个不被你的系统信任的私有CA颁发的,你的浏览器会弹出警告甚至阻止访问。在这种情况下,你需要手动安装服务器的证书,告诉你的系统“我信任这个证书,请不要再警告我了”。

所以,让我们回到公钥和私钥上。常见的算法有如图示3种。不同算法支持与推荐的长度也是不一样的。但从应用来说我们并不关心这些,大多数情况下只需要将公钥复制粘贴到github账号、服务器和主机的authorized_keys中即可。

ip

一个 IP,就好比一座房子的经纬度坐标,用于唯一标记网络上的一个位置。ip有ipv4和ipv6之分,其中ipv4为32bit,ipv6为128bit,之所以发展ipv6的原因之一是ipv4的地址资源紧张,不够使用。

按照TCP/IP协议,ip为二进制表示,为了方便使用,ip地址经常被写作4个8bit,中间用.隔开。

TCP/IP 协议需要针对不同的网络进行不同的设置,且每个节点一般需要一个“IP地址”、一个“子网掩码”、一个“默认网关”。这些可以手动设定,也可以通过动态主机配置协议(DHCP)给客户端自动分配一个地址。关于DHCP的内容这里暂且不作讨论。

由于分配不合理以及 IPv4 协议本身存在的局限,现在互联网的 IP 地址资源越来越紧张,这里需要提及一个叫 IANA(Internet Assigned Numbers Authority,互联网网络号分配机构)的组织,其工作内容的一部分便是划分了互联网五类ABCDE类地址,这里只介绍前三类ABC类,在这3种主要类型里,我们主要关注各自保留的3个作为私有地址的区域。

网络类别 保留IP 地址范围
A 类网 10.0.0.0~10.255.255.255
B 类网 172.16.0.0~172.31.255.255
C 类网 192.168.0.0~192.168.255.255

MAC 地址

MAC地址,也称为物理地址或硬件地址,是分配给网卡的唯一标识符。它是用来在局域网(LAN)中识别网络设备的。与IP地址不同,MAC地址是固化在硬件中的,一般不会改变。

子网掩码(Subnet Mask)

子网掩码(Subnet Mask)主要用于在计算机网络中用于确定 IP 地址的网络部分和主机部分,以达到划分子网的目的。

一种常见的表示方法是CIDR(无类别域间路由——一种无类别的 IP 地址分配方法)表示法,例如192.168.1.0/24/24代表该网域的掩码是255.255.255.0,前24bit都是1。而192.168.1.0则是网络地址,它是将满足以其为前缀的主机们的 IP 归为同一组的标识符。在192.168.1.0/24的网域下,最多可以分配256个地址,去除.0.255(广播地址)后,一共能分配254个不同的ip地址到各个主机。

当然,一种快速的辨识网络地址的方式是让主机的ip地址和子网掩码进行与(&)运算(虽然没有太大用处)。

广播地址

广播地址在 IP 地址中用于向同一网络内的所有设备发送消息或数据包。当报文发向广播地址时,子网内的每一台主机都会收到它。

1
2
3
4
robocon@rc-server:~$ ip addr show
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether e0:51:d8:15:1c:fd brd ff:ff:ff:ff:ff:ff
    inet 192.168.3.160/24 brd 192.168.3.255 scope global dynamic noprefixroute enp3s0
enp3s0 是一种在 Linux 系统中使用的以太网的接口名称,UP表示已被启用,LOWER_UP表示物理链路是连通的。mtu,qdisc等则定义了最大传输单元,队列调度等等信息,这里不展开解释。

link/ether表示链路层类型为以太网,e0:51:d8:15:1c:fd则表示了该接口的物理地址(MAC),ff:ff:ff:ff:ff:ff表示以太网的广播地址。 inet 192.168.3.160/24便是上文所讲的内容,brd 192.168.3.255表示子网的广播地址。

默认网关

默认网关(Default Gateway)是一个网络设备(通常是路由器、防火墙或三层交换机)的IP地址,它是本地网络中的设备访问其他网络(如外网、其他子网)时,数据报文的“第一站”。简单来说,当设备需要与非本网段的设备通信时,会自动将数据发送到默认网关,由其转发至目标网络。

需要指明的是。默认网关并不一定要是192.168.x.1,这只是常见习惯的设法。但需要遵循的是,默认网关的IP地址必须与你的计算机的IP地址处于同一个网段。

以太网

以太网是一种最广泛使用的、用于建立计算机局域网(LAN)的有线网络技术。一个以太网中的设备之间的连接方式有网线、光纤等。

无wifi连接

在电脑、树莓派均没有连接Wifi的情况下,我们以网线直连举例,大概讲述ip地址的分配关系:

  • 首先先引入网上的教程原理:在你插上网线连接树莓派之前,你需要打开你电脑控制面板下的"网络和Internet"=>" 网络连接"=>"右键点击你的以太网"=>选择属性=>"共享"=> 勾上"允许其他网络用户通过此计算机的internet连接来连接"

理想情况下,在此步骤之后会发生什么呢?Windows 会自动配置其以太网卡,使其充当一个简易的DHCP 服务器,为windows的以太网卡分配一个本地ip,通常是192.168.137.1(可以修改 );插上网线接入树莓派之后,理想情况下会发起 DHCP 请求,请求windows分配一个ipv4给到树莓派。这是一种比较理想的情况。

但事实上在些许情况下并非如此,原因是多种多样的我们并不在这里展开讨论。一个更为保险的方法是为树莓派的eth0接口分配一个静态ip4例如192.168.1.2,同时将windows的以太网接口的ip4固定到与树莓派同一网段下的192.168.1.x,将子网掩码设置为255.255.255.0,如此在同一子网下,windows才能ping到树莓派。

DHCP

DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)是一种网络协议,用于自动为网络中的设备分配 IP 地址和其他网络配置参数(如子网掩码、默认网关等)。它的主要目标是确保设备能够快速、正确地接入网络并进行通信,而无需用户手动设置 IP 地址。一般是路由器提供DHCP服务。

例如我的笔记本连上网后向路由器发送DHCP discover,请求路由器分配一个ip地址,之后路由器再响应说“可以用 192.168.1.100,网关是 192.168.1.1,DNS 是 8.8.8.8”,接着笔记本再发送请求使用上述的ip,然后服务器最终确认并且规定相关租约。

多线程与并发编程

进程是操作系统进行资源分配和调度的基本单位。简单来说,一个进程就是一个正在执行的程序实例。例如一个word文档被启动时,操作系统会为其创建一个进程,得益于操作系统虚拟持久化的特性,每个进程都认为自己拥有整个CPU和内存。进程之间是相互隔离的,进程之间需要额外的机制实现通信。

线程,在计算机科学中,是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。它有一些核心的组成部分例如栈,上下文,寄存器等。

POSIX 线程是 IEEE POSIX 标准中定义的一组多线程编程接口(C API),用于在类 UNIX 系统上创建和管理线程。它规范了线程创建、同步、属性设置、线程局部存储等功能,以保证可移植性。其可以用#include <pthread>进行引入,也提供了一些常用的线程接口。

  • 线程控制:pthread_create(), pthread_join(), pthread_exit(), pthread_detach()

  • 互斥锁:pthread_mutex_init(), pthread_mutex_lock(), pthread_mutex_unlock()

  • 条件变量:pthread_cond_wait(), pthread_cond_signal()

  • 线程属性:pthread_attr_init(), pthread_attr_setstacksize()

  • 线程局部存储(TLS):pthread_key_create(), pthread_setspecific()

Introduction

join()是 C++11 中 std::thread 类的一个重要方法,用于阻塞当前线程,直到被join() 的那个线程执行结束。这是线程同步中最常用的手段之一。

这里使用的头文件thread是C++11的标准库线程,有一些 RAII、异常和与 C++ 内存模型/同步原语集成这样的东西。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# include <iostream>
# include <thread> 

int main(int argc,char* argv[])
{
  std::cout << "Hello from main thread " <<std::this_thread::get_id() << std::endl;
  std::thread t([](){
      std::cout << "Hello from new thread " <<std::this_thread::get_id() << std::endl;
    });
  // t.join(); 让主线程(或调用线程)等待子线程执行完毕后再继续往下执行。
  t1.detach();

  // if (t1.joinable()) t1.join();
  return 0;
}
假设t1是一个长时间运行的线程,我的主线程不想等待T1完成,因为它有自己的事务需要处理,使用t1.detach(),t1将与主线程分离,在后台运行且无终端关联,之后由c++ 的运行时将负责回收t1的资源。
1
2
rc@rc-MIX:~/tutorial/third_tutorial/build$ ./RCtutorial 
Hello from main thread 128708025471936
我们并没有看到t1的线程号。因为t1.detach()使得新线程与主线程分离,主线程不会等待新线程结束。由于主线程很快就执行到 return 0; 并退出进程,可能在新线程来得及输出 thread id 之前,整个程序就已经结束了。

通常情况下我们并无法确定哪个线程会运行得更快,除非使用一些同步机制。

可以通过if (t1.joinable()) t1.join()的方式进行调整。我们不能够对已分离(detach)或已 join 的线程再次调用 join(),否则程序会抛出system_error。一个好的方法是在添加joinable()进行判断。

线程管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void Echo(std::string s) // save reference
{
  std::cout << "Hello from new thread " << std::this_thread::get_id() << std::endl;
}
int main(int argc,char* argv[])
{
  std::string s = "Hard Time comes again and never goes away";
  //   std::thread t(Echo,s);
  std::thread t(Echo , s);

  t.join();

  std::cout << "From main thread " << s << std::endl;
  if (t.joinable()) t.join();
  return 0;
}
因为参数是一个字符串,我们更想通过引用而不是值传递,因为这样可以节省很多副本的开销,所以我们将传参类型改为引用传递。但尽管如此,参数string s 仍然是通过值传递给线程t,因为线程的参数总是通过值传递的。t.join()确保主线程等待t运行完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# include <iostream>  
# include <thread>

void Echo(std::string& s) // &  
{  
std::cout << "Hello from new thread " << std::this_thread::get_id() << std::endl;  
// std::cout << "From thread " << s << std::endl;  
s += " – modified by thread";  
}  
int main(int argc,char* argv[])  
{  
std::string s = "Hard Time comes again and never goes away";  
// std::thread t(Echo,s);  
std::thread t(Echo,std::ref(s));

t.join();

std::cout << "From main thread " << s << std::endl;  
if (t.joinable()) t.join();  
return 0;
输出:
1
2
3
rc@rc-MIX:~/tutorial/third_tutorial/build$ ./move  
Hello from new thread 129109080405568  
From main thread Hard Time comes again and never goes away
可以看到输出的字符串中并没有" - modified by thread"。

RAII Intro

RAII(Resource Acquisition Is Initialization,中文翻译为资源获取即初始化。资源的使用一般经历三个步骤a.获取资源 b.使用资源 c.销毁资源,但是资源的销毁往往是程序员经常忘记的一个环节。因而实现自动资源销毁成为了这个情形下的答案。例如一位懒惰的程序员不想费心管理各个线程的join()方法来等待线程结束释放资源,于是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# include <iostream>  
# include <thread>

class wrapper {  
public:  
explicit wrapper(std::thread& t) : t_(t) {}  
~wrapper() {  
if (t_.joinable()) t_.join();  
}  
wrapper(const wrapper&) = delete;  
wrapper& operator=(const wrapper&) = delete;  
private:  
std::thread& t_;  
};

void Echo()  
{  
std::cout << "Hello from t1 thread " << std::this_thread::get_id() << std::endl;  
}

int main(int argc,char_ argv[])  
{  
std::cout << "Hello from main thread " << std::this_thread::get_id() << std::endl;  
std::thread t1(Echo);  
wrapper w(t1); // RAII自动管理线程  
return 0;  
}
explicit 是一个修饰符,用于声明构造函数和转换运算符,以防止编译器进行隐式类型转换。= delete表示显式删除一个函数。这里表示禁用wrapper类的拷贝构造和赋值运算符。这样有利于防止您有意无意使用了多个wrapper管理同一个线程对象,避免重复导致异常。

~wrapper()是析构函数,用于wrapper类生命周期结束时自动调用。这里在wrapper类结束时尝试调用t.join(),使得wrapper类等待其所管理的线程结束,之后wrapper才进行析构。

Data & Race

首先先引入信号量的概念,不严谨地说,信号量(semaphore)是用于进程/线程间同步的一个整数计数器,带有两种原子操作:P(也叫 wait 或 down)V(也叫 signal 或 up)。信号量在创建时有一个初值(非负),每次 P 操作会检查计数器:若大于 0 则将其减 1 并立刻返回,若等于 0 则调用者被阻塞(或以其他方式等待)直到有其他线程执行 V 操作;每次 V 操作将计数器加 1,并在必要时唤醒一个或多个等待者。这样,信号量可以用来限制同时访问某类有限资源的线程数量或用作事件/顺序同步。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# include <thread>
# include <iostream>
# include <mutex>
std::mutex mtx;
void sharedPrint(string msg,int id){
  mtx.lock();
  cout<<msg<<id<<endl;
  mtx.unlock();
}
void Counter(string id){
  for(int i=0;i<100;i++){
    sharedPrint(string("From t1: "),i);
  }
}

int main(int argc,char* argv[]){
  std::thread t1(Counter("t1"));
  for(int i=0;i<100;i++){
    sharedPrint(string("From main: "),i);
  }
  t1.join();
}
也就是说,当main thread占用std::cout的访问或者写操作时,t1 thread必须等待直到main thread释放其所持的std::cout的资源,这样保证了输出不会被多个线程交错,尽管maint1仍然可能交替输出,但其条目是明晰的。

但是这样两次调用mtx信号量还是太容易忘记了,这时您可以使用std::lock_guard<mutex> locker,用于实现基于作用域的互斥。当这行代码执行时,构造函数会立即对传入的互斥量加锁;当 locker 离开作用域析构时,会自动解锁。

Deadlock

死锁(Deadlock)是计算机科学里一个经典的问题,指的是两个或多个进程(或线程)在竞争资源时,因相互等待而进入一种僵持状态,导致它们都无法继续执行。

conditional_variable

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待条件变量的条件成立而挂起;另一个线程使条件成立(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥量结合在一起。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void wait( std::unique_lock<std::mutex>& lock );
//Predicate是lambda表达式:
template< class Predicate >
void wait( std::unique_lock<std::mutex>& lock, Predicate pred );

std::mutex mtx;
std::condition_variable cv;

void worker() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return ready; }); // 自动释放锁,等待ready
    // 唤醒时再重新获取锁,安全检查 ready
    std::cout << "Worker is running\n";
}
int main(){
    std::thread Worker(worker);
    // something that modifies ready to True ...
    cv.notify_one(); // 通知第一个进入阻塞或者等待的线程。
    cv.notify_all() //通知全部进入阻塞或者等待的线程 notify_all
}
下面这份代码实现了一个典型的多线程生产者-消费者模型,使用了互斥锁mtx保证队列操作的线程安全以及条件变量cv实现线程间同步和等待。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <condition_variable>

std::queue<int> FakeDiskScheduler;
std::mutex mtx;
std::condition_variable cv;
bool done = false; 

void Producer(int number) {
    for (int i = 0; i < number; i++) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            FakeDiskScheduler.push(i);
            std::cout << "Produced: " << i << std::endl;
            cv.notify_all(); // cv.notify_one() 的输出更为更为规律
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    {
        std::lock_guard<std::mutex> lock(mtx);
        done = true;
    }
    cv.notify_all();
}

void Consumer(std::string name) {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [] { return !FakeDiskScheduler.empty() || done; });  // true 才返回
        if (FakeDiskScheduler.empty() && done) {
            break;
        }
        if (!FakeDiskScheduler.empty()) {
            int value = FakeDiskScheduler.front();
            FakeDiskScheduler.pop();
            std::cout << name << " Consumed: " << value << std::endl;
        }
    }
}

int main() {
    int number = 10;
    std::thread producer(Producer, number);
    std::thread consumer1(Consumer, "Thread1");
    std::thread consumer2(Consumer, "Thread2");
    producer.join();
    consumer1.join();
    consumer2.join();
    return 0;
}
Producer(生产者)线程不断向队列FakeDiskScheduler中生产数据(push数字),每生产一个数据就通知所有等待的消费者线程(cv.notify_all),并休眠100ms。Consumer(消费者)线程不断等待队列有数据(cv.wait),一旦有数据就取出并消费(pop并打印),如果队列空且生产结束(done为true),则退出循环。

需要解释的一点是,cv.wait(lock, [] { return !FakeDiskScheduler.empty() || done;这句话的作用是,线程调用 cv.wait(lock, pred)时,先检查 lambda表达式的返回值,如果为True则直接不进入等待;若为false则释放lock持有的互斥量,将线程加入 cv 的等待队列,阻塞,直到被 cv.notify_one() / cv.notify_all() 唤醒,再次加锁。