第4章 C++多线程系统编程精要

第4章 C++多线程系统编程精要

4.1 引言

学习多线程编程面临的最大的思维方式的转变有以下两点:

  • 当前线程可能随时会被切换出去,或者说被抢占(preempt)了
  • 多线程程序中事件的发生顺序不再有全局统一的先后关系

多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep())来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。无论线程执行得快与慢(被操作系统切换出去得越多,执行越慢),程序都应该能正常工作。

例如下面这段代码就有这方面的问题。

bool running = false;//全局标志
void threadFunc() {while(running){//get task from queue}
}
void start() {muduo::Thread t(threadFunc);t.start();running = true;//应该放到t.start()之前
}
  • 这段代码暗中假定线程函数的启动慢于running变量的赋值,因此线程函数能进入while循环执行我们想要的功能。
  • 但是,直到有一天,系统负载很高,Thread::start()调用pthread_create()陷入内核后返回时,内核决定换另外一个就绪任务来执行。于是running的赋值就推迟了,这时线程函数就可能不进入while循环而直接退出了。
  • 有人会认为在while之前加一小段延时(sleep)就能解决问题,但这是错的,无论加多大的延时,系统都有可能先执行while的条件判断,然后再执行running的赋值。
  • 正确的做法是把running的赋值放到t.start()之前,这样借助pthread_create()的happens-before语意来保证running的新值能被线程看到。

4.2 基本线程原语的选用

POSIX threads的函数有110多个,真正常用的不过十几个。而且在C++程序中通常会有更为易用的 wrapper,不会直接调用Pthreads函数。

这11个最基本的Pthreads函数是:

  • 2个:线程的创建和等待结束(join)。封装为 muduo::Thread。
  • 4个:mutex的创建、销毁、加锁、解锁。封装为 muduo::MutexLock。
  • 5个:条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition。

用这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如 mutex::ThreadPool 和 mutex::CountDownLatch 等。

除此之外,Pthreads还提供了其他一些原语,有些是可以酌情使用的,有些则是不推荐使用的。

可以酌情使用的有:

  • pthread_once,封装为muduo::Singleton。其实不如直接用全局变量。
  • pthread_key*,封装为muduo::ThreadLocal。可以考虑用__thread替换之。

不建议使用:

  • pthread_rwlock,读写锁通常应慎用。muduo没有封装读写锁,这是有意的。
  • sem_*,避免用信号量(semaphore)。它的功能与条件变量重合,但容易用错。
  • pthread_{cancel, kill}。程序中出现了它们,则通常意味着设计出了问题。

不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的mutex相比,它实际上降低了性能。另外,写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的。

多线程系统编程的难点不在于学习线程原语(primitives),而在于理解多线程与现有的C/C++库函数和系统调用的交互关系,以进一步学习如何设计并实现线程安全且高效的程序。

4.3 C/C++系统库的线程安全性

现行的C/C++标准(C89/C99/C++03)并没有涉及线程。

新版的C/C++标准(C11和C++11)规定了程序在多线程下的语意,C++11还定义了一个线程库(std::thread)。

对于标准而言,关键的不是定义线程库,而是规定内存模型(memory model)。特别是规定一个线程对某个共享变量的修改何时能被其他线程看到,这称为内存序(memory ordering)或者内存能见度(memory visibility)。

线程的出现给出现在20世纪90年代Unix操作系统的系统函数库带来了冲击,破坏了20年来一贯的编程传统和假定。

例如:

  • errno不再是一个全局变量,因为每个线程可能会执行不同的系统库函数。
  • 有些“纯函数”不受影响,例如memset/strcpy/snprintf等等。
  • 有些影响全局状态或者有副作用的函数可以通过加锁来实现线程安全,例如malloc/free、printf、fread/fseek等等。
  • 有些返回或使用静态空间的函数不可能做到线程安全,因此要提供另外的版本,例如asctime_r/ctime_r/gmtime_r、stderror_r、strtok_r等等。
  • 传统的fork()并发模型不再适用于多线程程序

现在Linux glibc把errno定义为一个宏,注意errno是一个lvalue,因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference。

extern int *__errno_location(void);
#define errno (*__errno_location())

现在glibc库函数大部分都是线程安全的。特别是FILE*系列函数是安全的,glibc甚至提供了非线程安全的版本以应对某些特殊场合的性能需求。

尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了。

例如fseek()和fread()都是安全的

  • 但是对某个文件“先seek再read”这两步操作中间有可能会被打断,其他线程有可能趁机修改了文件的当前位置,让程序逻辑无法正确执行。
  • 在这种情况下,我们可以用flockfile(FILE*)funlockfile(FILE*)函数来显式地加锁。并且由于FILE*的锁是可重入的,加锁之后再调用fread()不会造成死锁。

如果程序直接使用lseek和read这两个系统调用来随机读取文件,也存在“先seek再read”这种race condition,但是似乎我们无法高效地对系统调用加锁。解决办法是改用pread系统调用,它不会改变文件的当前位置。

由此可见,编写线程安全程序的一个难点在于线程安全是不可组合的(composable),一个函数foo()调用了两个线程安全的函数,而这个foo()函数本身很可能不是线程安全的。即便现在大多数glibc库函数是线程安全的,我们也不能像写单线程程序那样编写代码。

例如,在单线程程序中,如果我们要临时转换时区,可以用tzset()函数,这个函数会改变程序全局的“当前时区”。

// 保存当前的时区设置
string oldTz = getenv("TZ");
// 设置时区为欧洲伦敦 (Europe/London)
putenv("TZ=Europe/London");
// 更新时区设置
tzset();// 定义一个结构体用于存储伦敦的本地时间
struct tm localTimeInLN;
// 获取当前时间戳
time_t now = time(NULL);
// 将当前时间戳转换为伦敦时区的本地时间,并存储在localTimeInLN 中
localtime_r(&now, &localTimeInLN);
// 恢复之前保存的时区设置
setenv("TZ", oldTz.c_str(), 1);
// 更新时区设置,使其回到之前的设置
tzset();

但是在多线程程序中,这么做不是线程安全的,即便tzset()本身是线程安全的。

因为它改变了全局状态(当前时区),这有可能影响其他线程转换当前时间,或者被其他进行类似操作的线程影响。

解决办法是使用muduo::TimeZone class,每个immutable instance对应一个时区,这样时间转换就不需要修改全局状态了。

例如:

// 自定义 TimeZone 类
class TimeZone {
public:// 构造函数,接受时区文件路径explicit TimeZone(const char* zonefile);// 将时间戳转换为特定时区的本地时间struct tm toLocalTime(time_t secondsSinceEpoch) const;// 将特定时区的本地时间转换为时间戳time_t fromLocalTime(const struct tm&) const;// 其他可能的成员函数...
};// 定义常量表示纽约时区和伦敦时区
const TimeZone kNewYorkTz("/usr/share/zoneinfo/America/New_York");
const TimeZone kLondonTz("/usr/share/zoneinfo/Europe/London");// 获取当前时间戳
time_t now = time(NULL);
// 将当前时间戳转换为纽约时区的本地时间
struct tm localTimeInNY = kNewYorkTz.toLocalTime(now);
// 将当前时间戳转换为伦敦时区的本地时间
struct tm localTimeInLN = kLondonTz.toLocalTime(now);

一个基本思路是尽量把class设计成immutable的,这样用起来就不必为线程安全操心了。

尽管C++03标准没有明说标准库的线程安全性,但我们可以遵循

  • 一个基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的。
  • 一个事实标准:共享的对象的read-only操作是安全的,前提是不能有并发的写操作。

例如:

  • 两个线程各自访问自己的局部vector对象是安全的;
  • 同时访问共享的const vector对象也是安全的,但是这个vector不能被第三个线程修改。一旦有writer,那么read-only操作也必须加锁,例如vector::size()。

C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)。

假设有safe_vectorclass,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Javasynchronized方法)。但是用safe_vector并不一定能写出线程安全的代码。

例如:

safe_vector<int> vec;//全局可见
if(!vec.empty()) { //没有加锁保护int x = vec[0];//这两步在多线程下是不安全的
}

在if语句判断vec非空之后,别的线程可能清空其元素,从而造成vec[0]失效。

C++标准库中的绝大多数泛型算法是线程安全的,因为这些都是无状态纯函数。只要输入区间是线程安全的,那么泛型函数就是线程安全的。

C++的iostream不是线程安全的,因为流式输出

std::cout << "Now is " << time(NULL);

等价于两个函数调用

std::cout.operator<<("Now is ").operator<<(time(NULL));

即便ostream::operator<<()做到了线程安全,也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符。

对于“线程安全的stdout输出”这个需求,我们可以改用printf,以达到安全性和输出的原子性。但是这等于用了全局锁,任何时刻只能有一个线程调用printf,恐怕不见得高效。

4.4 Linux上的线程标识

POSIX threads库提供了pthread_self函数用于返回当前进程的标识符,其类型为pthread_t。pthread_t不一定是一个数值类型(整数或指针),也有可能是一个结构体,因此Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等。

这就带来一系列问题,包括:

  • 无法打印输出pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程的id。
  • 无法比较pthread_t的大小或计算其hash值,因此无法用作关联容器的key。
  • 无法定义一个非法的pthread_t值,用来表示绝对不可能存在的线程id,因此MutexLock class没有办法有效判断当前线程是否已经持有本锁。
  • pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比方说在/proc文件系统中找不到pthread_t对应的task。

glibc的Pthreads实现实际上把pthread_t用作一个结构体指针(它的类型是unsigned long),指向一块动态分配的内存,而且这块内存是反复使用的。

这就造成pthread_t的值很容易重复。Pthreads只保证同一进程之内,同一时刻的各个线程的id不同;不能保证同一进程先后多个线程具有不同的id,更不要说一台机器上多个进程之间的id唯一性了。

例如下面这段代码中先后两个线程的标识符是相同的:

int main(){pthread_t t1,t2;pthread_create(&t1,NULL,threadFunc,NULL);printf("%lx\n",t1);pthread_join(t1,NULL);pthread_create(&t2,NULL,threadFunc,NULL);printf("%lx\n",t2);pthread_join(t2,NULL);
}
$ ./a.out
7fad11787700
7fad11787700

因此,pthread_t并不适合用作程序中对线程的标识符。

在Linux上,作者建议使用gettid系统调用的返回值作为线程id,这么做的好处有:

  • 它的类型是pid_t,其值通常是一个小整数13,便于在日志中输出。
  • 在现代Linux中,它直接表示内核的任务调度id,因此在/proc文件系统中可以轻易找到对应项:/proc/tid或/prod/pid/task/tid。
  • 在其他系统工具中也容易定位到具体某一个线程,例如在top中我们可以按线程列出任务,然后找出CPU使用率最高的线程id,再根据程序日志判断到底哪一个线程在耗用CPU。
  • 任何时刻都是全局唯一的,并且由于Linux分配新pid采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程id。
  • 0是非法值,因为操作系统第一个进程init的pid是1。

但是glibc并没有封装这个系统调用,需要我们自己实现。

作者封装的gettid的方法如下:

muduo::CurrentThread::tid()采取的办法是用__thread变量来缓存gettid的返回值,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果,效率无忧。

未完待续。。。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/168182.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

软著项目推荐 深度学习 opencv python 实现中国交通标志识别

文章目录 0 前言1 yolov5实现中国交通标志检测2.算法原理2.1 算法简介2.2网络架构2.3 关键代码 3 数据集处理3.1 VOC格式介绍3.2 将中国交通标志检测数据集CCTSDB数据转换成VOC数据格式3.3 手动标注数据集 4 模型训练5 实现效果5.1 视频效果 6 最后 0 前言 &#x1f525; 优质…

游览器缓存讲解

浏览器缓存是指浏览器在本地存储已经请求过的资源的一种机制&#xff0c;以便在将来的请求中能够更快地获取这些资源&#xff0c;减少对服务器的请求&#xff0c;提高页面加载速度。浏览器缓存主要涉及到两个方面&#xff1a;缓存控制和缓存位置。 缓存控制 Expires 头&#…

Javascript每天一道算法题(十六)——获取除自身以外数组的乘积_中等

文章目录 1、问题2、示例3、解决方法&#xff08;1&#xff09;方法1 总结 1、问题 给你一个整数数组 nums&#xff0c;返回 数组 answer &#xff0c;其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。 题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀…

RAM模型从数据准备到pretrain、finetune与推理全过程详细说明

提示&#xff1a;RAM模型&#xff1a;环境安装、数据准备与说明、模型推理、模型finetune、模型pretrain等 文章目录 前言一、环境安装二、数据准备与解读1.数据下载2.数据标签内容解读3.标签map内容解读 三、finetune训练1.微调训练命令2.load载入参数问题3.权重载入4.数据加载…

使用new Vue()的时候发生了什么?

前言 Vue.js是一个流行的JavaScript前端框架&#xff0c;用于构建单页面应用&#xff08;SPA&#xff09;和用户界面。当我们使用new Vue()来创建一个Vue实例时&#xff0c;Vue会执行一系列的初始化过程&#xff0c;将数据变成响应式&#xff0c;编译模板&#xff0c;挂载实例…

RabbitMQ之发送者(生产者)可靠性

文章目录 前言一、生产者重试机制二、生产者确认机制实现生产者确认&#xff08;1&#xff09;定义ReturnCallback&#xff08;2&#xff09;定义ConfirmCallback 总结 前言 生产者重试机制、生产者确认机制。 一、生产者重试机制 问题&#xff1a;生产者发送消息时&#xff0…

分布式事务总结

文章目录 一、分布式事务基础什么是事务&#xff1f;本地事物分布式事务分布式事务的场景 二、分布式事务解决方案全局事务可靠消息服务TCC 事务 三、Seata 分布式事务解决方案3.1 Seata-At模式3.2 秒杀项目集成 Seata启动 Seata-Server项目集成seata配置AT模式代码实现 3.3 Se…

openstack(2)

目录 块存储服务 安装并配置控制节点 安装并配置一个存储节点 验证操作 封装镜像 上传镜像 块存储服务 安装并配置控制节点 创建数据库 [rootcontroller ~]# mysql -u root -pshg12345 MariaDB [(none)]> CREATE DATABASE cinder; MariaDB [(none)]> GRANT ALL PR…

1、Docker概述与安装

相关资源网站&#xff1a; ● docker官网&#xff1a;http://www.docker.com ● Docker Hub仓库官网: https://hub.docker.com/ 注意&#xff0c;如果只是想看Docker的安装&#xff0c;可以直接往下拉跳转到Docker架构与安装章节下的Docker具体安装步骤&#xff0c;一步步带你安…

82基于matlab GUI的图像处理

基于matlab GUI的图像处理&#xff0c;功能包括图像一般处理&#xff08;灰度图像、二值图&#xff09;&#xff1b;图像几何变换&#xff08;旋转可输入旋转角度、平移、镜像&#xff09;、图像边缘检测&#xff08;拉普拉斯算子、sobel算子、wallis算子、roberts算子&#xf…

【Rust日报】2023-11-22 Floneum -- 基于 Rust 的一款用于 AI 工作流程的图形编辑器

Floneum -- 基于 Rust 的一款用于 AI 工作流程的图形编辑器 Floneum 是一款用于 AI 工作流程的图形编辑器&#xff0c;专注于社区制作的插件、本地 AI 和安全性。 Floneum 有哪些特性&#xff1a; 可视化界面&#xff1a;您无需任何编程知识即可使用Floneum。可视化图形编辑器可…

oled的使用 动态的变量 51

源码均在IIC手写程序中 外部中断实现变量加一 #include "reg52.h" #include "main.h" #include <intrins.h> #include "OLED.h" #include "bmp.h" #include "Delay.h" sbit LED1 P1^0; sbit LED2 P1^1; sbit LED3…

【LeetCode每日一题】525. 连续数组

题目&#xff1a; 给定一个二进制数组 nums , 找到含有相同数量的 0 和 1 的最长连续子数组&#xff0c;并返回该子数组的长度。 妈的 连题目都没有读懂&#xff01;本来看成是找到两个连续子数组&#xff0c;两个连续子数组的 0 1 个数分别相同&#xff0c;我说怎么看着如此…

Python报错:AttributeError(类属性、实例属性)

Python报错&#xff1a;AttributeError&#xff08;类属性、实例属性&#xff09; Python报错&#xff1a;AttributeError 这个错误就是说python找不到对应的对象的属性&#xff0c;百度后才发现竟然是初始化类的时候函数名写错了 __init__应该有2条下划线&#xff0c;如果只有…

构建未来:云计算 生成式 AI 诞生科技新局面

目录 引言生成式 AI&#xff1a;开发者新伙伴云计算与生成式 AI 的无缝融合亚马逊云与生成式 AI 结合的展望/总结我用亚马逊云科技生成式 AI 产品打造了什么&#xff0c;解决了什么问题未来科技发展趋势&#xff1a;开发者的机遇与挑战结合实践看未来结语开源项目 引言 2023年…

SpectralGPT: Spectral Foundation Model 论文翻译1

遥感领域的通用大模型 2023.11.13在CVPR发表 原文地址&#xff1a;[2311.07113] SpectralGPT: Spectral Foundation Model (arxiv.org) 摘要 ​ 基础模型最近引起了人们的极大关注&#xff0c;因为它有可能以一种自我监督的方式彻底改变视觉表征学习领域。虽然大多数基础模型…

VSCode 连接远程服务器问题及解决办法

端口号不一样&#xff0c;需要在配置文件中添加Port Host 27.223.26.46HostName 27.223.*.*User userForwardAgent yesPort 14111输入密码后可以连接 在vscode界面&#xff0c;终端&#xff0c;生成公钥&私钥 ssh-keygen可以看到有id_rsa和id_rsa.pub两个文件生成&#…

curl 命令的一些基本用法,

curl 是一个用于在命令行中进行网络请求的工具。以下是一些 curl 命令的常见用法&#xff1a; 从 URL 下载文件并保存为本地文件&#xff1a; curl -O URL例如&#xff1a; curl -O https://example.com/file.zip这将会将 file.zip 下载到当前目录。 将文件下载到指定位置&…

Nginx如何配置负载均衡

nginx的负载均衡有4种模式&#xff1a; 1)、轮询&#xff08;默认&#xff09; 每个请求按时间顺序逐一分配到不同的后端服务器&#xff0c;如果后端服务器down掉&#xff0c;能自动剔除。 2)、weight 指定轮询几率&#xff0c;weight和访问比率成正比&#xff0c;用于后端服务…

C#,《小白学程序》第五课:队列(Queue)其一,排队的技术与算法

日常生活中常见的排队&#xff0c;软件怎么体现呢&#xff1f; 排队的基本原则是&#xff1a;先到先得&#xff0c;先到先吃&#xff0c;先进先出 1 文本格式 /// <summary> /// 《小白学程序》第五课&#xff1a;队列&#xff08;Queue&#xff09; /// 日常生活中常见…