目录
一、线程库
在C++11之前,涉及到多线程问题都是与自身的平台相关,比如 Windows 和 Linux 下的接口各不相同,代码的移植性比较差。而在C++11中便引入了线程库,使得C++在多线程编程下无需依赖第三方库。
有关多线程的具体细节见:【Linux】多线程-优快云博客
(一)基本概念
上图可知,线程支持无参和有参的构造函数,同时删除了拷贝函数,同时也提供了移动构造。
线程是操作系统的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
当我们使用无参构造创建线程对象后,该对象实际没有对应任何一个线程。当我们使用有参构造创建线程对象并给出关联线程函数后,该线程就会被启动,与主线程一起运行。
void func1(int x)
{
std::cout << x << std::endl;
}
class func2
{
public:
void operator()(int x)
{
std::cout << x << std::endl;
}
};
auto func3 = [](int x) {
std::cout << x << std::endl;
};
int main()
{
//使用普通函数作为关联函数
std::thread t1(func1, 1);
t1.join();
//使用仿函数作为关联函数
std::thread t2(func2(), 2);
t2.join();
//使用lambda表达式作为关联函数
std::thread t3(func3, 3);
t3.join();
return 0;
}
(二)独立栈结构
每个线程都具有各自独立的栈结构,当我们使用有参构造生成线程对象后,线程函数的参数实际是以值拷贝的方法拷贝到线程空间中的,在线程中修改后无法影响外部实参,如果只使用引用作为函数接收参数,则实际引用的是独立栈中的拷贝,而是实参本身。
//如果使用引用接收参数必须加上 const
// 因为实际引用的内容是独立栈拷贝后的内容,具有常性
//void func1(const int& x)
void func1(int x)
{
x += 1;
}
void func2(int& x)
{
x += 1;
}
void func3(int* x)
{
*x += 1;
}
int main()
{
int x = 0;
std::thread t1(func1, x); //普通传参
t1.join();
std::cout << "func1:" << x << std::endl;
//如果不使用 ref 进行处理,函数引用接收必须加上 const,否则编译报错
std::thread t2(func2, std::ref(x)); //引用传参
t2.join();
std::cout << "func2:" << x << std::endl;
std::thread t3(func3, &x); //传址传参
t3.join();
std::cout << "func3:" << x << std::endl;
return 0;
}
由结果得知线程 t1 并没有修改实参x的值,t2 和 t3 修改了实参 x 的值。
(三)互斥锁
因为线程间的执行顺序是具有随机性的,因为在有些时候可能并不能得出目标结果。
在以上场景下,变量 x 对于线程 t1 与 t2 而言相当于共享资源,在没有一定的同步互斥的保护下,多个线程通过修改同一变量可能会导致数据竞争,进而发生错误。
例如:当线程 t1 将变量 x 修改后写回内存前,线程 t2 将变量 x 读出修改,又因为线程 t1 还未将修改后的值写回,致使线程 t2 取出的值是 t1 修改前的值,当 t1 和 t2 将各自修改的值都写回内存后相当于少进行了一次++操作,最终导致结果值不符合目标值。
在以上情况就需要互斥锁对共享变量进行保护,实际上就是限制多个线程对同一变量进行同时访问,也就是只允许一个线程该变量进行修改。
int main()
{
int x = 0, n = 1000000;
std::mutex mtx;
std::thread t1([&] {
for (int i = 0; i < n; ++i)
{
//加锁对变量x进行互斥保护
mtx.lock();
++x;
mtx.unlock();
}
});
std::thread t2([&] {
for (int i = 0; i < n; ++i)
{
//加锁对变量x进行互斥保护
mtx.lock();
++x;
mtx.unlock();
}
});
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
以上代码是对数据竞争问题的解决,当线程需要修改变量 x 前加锁,当修改完成后再释放锁,保证了对数据的互斥访问。
但是以上代码实际还存在着能够优化的地方,以上代码的问题主要是:被保护的代码执行速度非常快,导致程序执行时大部分的时候都消耗在锁的执行与释放上,每一次需要修改变量时都需要申请与释放锁,致使真正修改数据的时间远小于申请释放锁的时间,因此我们需要将锁的申请与释放放在 for 循环外。
虽然将锁的申请和释放放在循环外会导致线程间完全串行执行,但是会极大的提高程序运行速度,但是并不是每个场景都需要将锁的申请和释放放在循环外,需要具体情况具体分析。以下是验证该场景下循环外申请释放锁比循环内的运行速度快。
int main()
{
int x = 0, n = 1000000000;
std::mutex mtx;
time_t begin1 = std::time(nullptr);
std::thread t1([&] {
for (int i = 0; i < n; ++i)
{
mtx.lock();
++x;
mtx.unlock();
}
});
t1.join();
time_t end1 = std::time(nullptr);
time_t begin2 = std::time(nullptr);
std::thread t2([&] {
mtx.lock();
for (int i = 0; i < n; ++i)
{
++x;
}
mtx.unlock();
});
t2.join();
time_t end2 = std::time(nullptr);
std::cout << "线程1执行时间:" << end1 - begin1 << std::endl;
std::cout << "线程2执行时间:" << end2 - begin2 << std::endl;
return 0;
}
(四)原子操作
在出现数据竞争问题时也可以采用原子操作解决,原子操作是指在计算机程序执行过程中,某个操作要么完全执行,要么完全不执行,不会在执行过程中被其他操作打断或干扰。
在系统中,原子操作是由 CAS 操作完成的,CAS操作是一种常见的并发编程技术,用于保证多个线程访问同一共享资源时的数据一致性。它可以在不使用锁的情况下实现对共享变量的原子更新操作。
CAS操作通常由三个参数组成:内存地址V、预期值A和新值B。当多个线程同时尝试更新V时,只有其中一个线程能够成功执行CAS操作,即当且仅当V的当前值等于A时,才会将其更新为B。如果V的当前值不等于A,则说明其他线程已经修改了V,那么当前线程会放弃更新操作,并重新尝试。
CAS操作通常用于实现无锁算法,在高并发场景中可以提高程序的并发性能。但是,CAS操作也存在一些问题,例如ABA问题和自旋次数过多等,需要开发者在实际应用中注意避免和解决。
使用原子操作也可以解决数据竞争的问题:
atomic可以使线程并行。底层的本质就是CAS(比较并交换)操作。在以上场景下,当需要修改数据时,会预先保存修改前的数值,当计算完毕需要写回内存时会检查写回前的数据和预先保存的数值是否相同,如果相同则正常将计算后的数值写入内存,如果不相同则放弃写入,通常会重试直到成功为止。
(五)lock_guard 与 unique_lock
lock_guard 与 unique_lock 类似,都是利用RAII对互斥锁进行管理,二者唯一的区别是 unique_lock 的使用比 lock_guard 更加灵活,可以随时进行加锁解锁。
1、lock_guard
lock_guard 只支持有参的构造,同时删除了拷贝函数。其使用方式其实与智能指针类同。
int main()
{
std::atomic<int> x = 0, n = 10000000;
std::mutex mtx;
std::thread t1([&] {
for (int i = 0; i < n; ++i)
{
std::lock_guard<std::mutex> lg(mtx);
++x;
}
});
std::thread t2([&] {
for (int i = 0; i < n; ++i)
{
std::lock_guard<std::mutex> lg(mtx);
++x;
}
});
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数 成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。 但用户没有办法对该锁进行控制,因此C++11又提供了 unique_lock。
2、unique_lock
与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动 (move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。
使用以上类型互斥量实例化 unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解 锁,可以很方便的防止死锁问题。与 lock_gurad 不同的是,unique_lock 更加灵活,提供了更多的成员函数,例如:上锁解锁,移动赋值等。
(六)条件变量
条件变量并不是线程安全的,所以在使用时需要传入互斥锁。
一个线程在调用wait函数后,会被阻塞挂起,同时释放自己手上的锁。当另一个线程调用notify_one函数后,将重新唤醒该线程,该线程自动获得当初释放的那把锁。所以,这也是wait函数传入的形参必须是unique_lock的原因。
使用条件变量控制偶数先打印的代码:
int main()
{
std::atomic<int> x = 0;
std::mutex mtx;
std::condition_variable cv;
//打印奇数
std::thread t1([&] {
std::unique_lock<std::mutex> lg(mtx);
while (x < 100)
{
while (x % 2 == 0)
cv.wait(lg);
std::cout << "t1 -> " << x++ << std::endl;
cv.notify_one();
}
});
//打印偶数
std::thread t2([&] {
std::unique_lock<std::mutex> lg(mtx);
while(x < 100)
{
while (x % 2)
cv.wait(lg);
std::cout << "t2 -> " << x++ << std::endl;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
针对代码进行简单分析:
假如 线程t1 先执行,因为 x = 0 进入循环被阻塞在 wait() 并释放锁,当 线程t2 执行时首先会获得锁,因为 x = 0 不会进入循环等待,将内容打印后 ++x 并将 线程t1 唤醒,之后 线程t1 会循环执行,因为此时 x = 1 进入循环被阻塞在 wait 并释放锁,唤醒后的 线程t1 会重新获得锁并检查循环条件,此时 x = 1 因此跳出循环,线程t1 执行打印内容并 ++x 后唤醒线程 t1, 之后会继续执行因为 x = 2 被阻塞在 wait() 后再释放锁,线程t2 获得锁并因为 x = 2 跳出循环。通过以上的操作从而保证了奇数偶数交替打印。
二、IO流
(一)什么是流
“流”即是流动的意思,是物质从一处向另一处流动的过程,是对一种有序连续且具有方向性的数据的抽象描述。
C++流是指信息从外部输入设备(如键盘)向计算机内部(如内存)输入和从内存向外部输出设备(显示器)输出的过程。这种输入输出的过程被形象的比喻为“流”。
C++为了支持这些流操作,定义了IO标准库,这些每个类都称为流/流类,用以完成某方面的功能。
(二)C++ IO流
C++实现了一个庞大的类库,其中 ios 是基类,其他类都直接或简介继承了基类。
1、C++标准IO流
C++标准库提供了4个全局对象 cin、 cout、cerr 和 clog。其中 cin 为标准输入即从键盘输入至程序中,而 cout 为标准输出即数据由内存流向控制台,C++还支持使用 cerr 输出标准错误以及 clog 进行日志输出。由上图可知 cout、cerr和clog是 ostream的三个对象,三者使用没有太大的差异,主要是应用场景不同。
C++基于系统拥有属于自己的缓冲区,无论是输入还是输出实际都会经过缓冲区,需要注意的是:当我们使用 cin 进行标准输入时是将空格或回车作为输入数据之间的分隔符,也就是无法使用cin 把空格和回车作为数据进行存储。
(1)cin 和 cout 的重载
针对于内置类型可以直接使用 cin 和 cout 进行输入输出,而对于自定义类型我们实际可以重载 cin 和 cout 实现自定义类型的打印。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
struct Point
{
int _x = 0;
int _y = 0;
Point(int x = 0, int y = 0)
:_x(x), _y(y)
{}
};
ostream& operator<<(ostream& out, const Point& p)
{
out << p._x << " " << p._y;
return out;
}
istream& operator>>(istream& in, Point& p)
{
in >> p._x >> p._y;
return in;
}
int main()
{
Point a;
cin >> a;
cout << a << endl;
return 0;
}
(2)istream类型对象转换为逻辑条件判断值
在一些编程题中,我们可能会见到如下代码:
int main()
{
string s;
while (cin >> s)
{
cout << s << endl;
}
return 0;
}
cin标准输入流是如何作为 while 循环条件的呢?实际上标准输入流返回的是流输入。
实际上C++对此还进行一些特殊操作,重载了 operator bool(),该函数将标准输入流对象按照输入的内容转换为布尔值进行返回,我们可以举个例子进行类似操作的说明。
2、C++文件IO流
相较于C语言的文件操作,C++的文件操作更加面向对象,使用方便。
ifstream ififile(只输入用)
ofstream ofifile(只输出用)
fstream iofifile(既输入又输出用)
(1)二进制读写
使用二进制读写实际就是在读写时多选个读写模式即可。
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
struct Data
{
char _msg[32];
int _port;
};
struct dataManager
{
public:
dataManager(string filename = "")
:_filename(filename)
{}
//写入文件
void fileWrite(const Data& data)
{
ofstream ofs(_filename, ofstream::out | ofstream::binary);
ofs.write((const char*)&data, sizeof(data));
}
//读出文件
void fileRead(Data& data)
{
ifstream ifs(_filename, ifstream::in | ifstream::binary);
ifs.read((char*) & data, sizeof(data));
}
private:
string _filename;
};
int main()
{
dataManager dm("test.txt");
Data data1 = { "192.0.0.1", 80 };
Data data2;
dm.fileWrite(data1);
dm.fileRead(data2);
cout << data2._msg << " " << data2._port << endl;
return 0;
}
需要注意的是,当我们使用 write 和 read 接口时,不能直接将 string 类型的字符串直接进行读写。string 类型中并不是单单只有数据本身,而包含了一定的数据结构(指针)来维护数据内容,当我们直接写入或读出时实际读写的数据不仅仅只有数据本身,还有string类型自身的体系结构。
当我们使用 write 和 read 接口直接读写 string 非常容易程序崩溃。例如:程序 A 将string对象直接写入文件,程序B使用 string 对象直接接收文件内容,因为程序 A 和 程序B 的虚拟空间相互独立,很有可能造成野指针访问的问题。
(2)文本读写
文本读写不仅仅可以使用 write 和 read 进行文本操作,还可以使用流来操作文件。
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
struct Data
{
string _msg;
int _port;
};
ostream& operator<<(ostream& out,const Data& data)
{
out << data._msg << " " << data._port;
return out;
}
istream& operator>>(istream& in, Data& data)
{
in >> data._msg >> data._port;
return in;
}
struct dataManager
{
public:
dataManager(string filename = "")
:_filename(filename)
{}
//写入文件
void fileWrite(const Data& data)
{
ofstream ofs(_filename);
ofs << data << endl;
}
//读出文件
void fileRead(Data& data)
{
ifstream ifs(_filename);
ifs >> data;
}
private:
string _filename;
};
int main()
{
dataManager dm("test.txt");
Data data1 = { "192.0.0.1", 80 };
Data data2;
dm.fileWrite(data1);
dm.fileRead(data2);
cout << data2._msg << " " << data2._port << endl;
return 0;
}
(四)stringstream
1、概念
在程序中如果想要使用stringstream,必须要包含头文件。在该头文件下,标准库三个类: istringstream、ostringstream 和 stringstream,分别用来进行流的输入、输出和输入输出操作。
stringstream实际是在其底层维护了一个 string 类型的对象用来保存结果。在对单一stringstream重复使用时要需要对内容的清空 clear(),可以使用s.str()将让stringstream返回其底层的string对象。
stringstream使用string类对象代替字符数组,可以避免缓冲区溢出的危险,而且其会对参 数类型进行推演,不需要格式化控制,也不会出现格式化失败的风险,因此使用更方便,更 安全。
2、拼接字符串
可以利用stringstream的特性来完成一些字符串拼接的简单工作。
#include <iostream>
#include <string>
#include <sstream>
int main()
{
stringstream sstream;
sstream << "first second third";
string str;
while (sstream >> str)
{
cout << str << endl;
}
}
3、序列化与反序列化
stringstream不仅仅用于将数据转换为字符串,还可用于将数据进行序列化或是反序列化。
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
struct point
{
int _x = 0;
int _y = 0;
point(int x = 0, int y = 0)
:_x(x), _y(y)
{}
};
ostream& operator<<(ostream& out,const point& p)
{
out << p._x << " " << p._y;
return out;
}
istream& operator>>(istream& in, point& p)
{
in >> p._x >> p._y;
return in;
}
int main()
{
int a1 = 1;
double b1 = 2.0;
point p1(3, 4);
//序列化
ostringstream os;
os << a1 << " " << b1 << " " << p1;
string str = os.str();
cout << str << endl;
int a2;
double b2;
point p2;
//反序列化
istringstream is(str);
is >> a2 >> b2 >> p2;
cout << a2 << " " << b2 << " " << p2 << endl;
return 0;
}
以上代码中的 istringstream 和 ostringstream 可以只使用 stringstream 来完成操作。虽然 stringstream 可以完成序列化以及反序列化,但是使用空格或换行分割数据太过单一,实际很少使用 stringstream 进行序列化和反序列。