【C+】C++11 —— 线程库

news2025/7/19 17:09:51

在这里插入图片描述

​📝个人主页:@Sherry的成长之路
🏠学习社区:Sherry的成长之路(个人社区)
📖专栏链接:C++学习
🎯长路漫漫浩浩,万事皆有期待

上一篇博客:【C++】C++11 ——— 可变参数模板

文章目录

  • 线程库
    • 线程库(thread)
      • 线程对象的构造方式
      • thread提供的成员函数
      • 获取线程的id的方式
      • 线程函数的参数问题
      • join与detach
    • 互斥量库(mutex)
      • mutex的种类
      • lock_guard和unique_lock
    • 原子性操作库(atomic)
    • 条件变量库(condition_variable)
  • 实现两个线程交替打印1-100
  • 总结:

线程库

在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。

线程库(thread)

线程对象的构造方式

一、调用无参的构造函数

thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。比如:

thread t1;

由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t1;
	//...
	t1 = thread(func, 10);

	t1.join();
	return 0;
}

场景: 实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。

二、调用带参的构造函数

thread的带参的构造函数的定义如下:

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

参数说明:
fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
args…:调用可调用对象fn时所需要的若干参数。

调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t2(func, 10);

	t2.join();
	return 0;
}

三、调用移动构造函数

thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t3 = thread(func, 10);

	t3.join();
	return 0;
}

说明一下:
线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。

thread提供的成员函数

thread中常用的成员函数如下:

成员函数		功能
join		对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞
joinable	判断该线程是否已经执行完毕,如果是则返回true,否则返回false
detach		将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待
get_id		获取该线程的id
swap		将两个线程对象关联线程的状态进行交换

此外,joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:

采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
线程已经调用join或detach结束。(线程已经结束)

获取线程的id的方式

调用thread的成员函数get_id可以获取线程的id,但该方法必须通过线程对象来调用get_id函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread命名空间下的get_id函数。比如:

void func()
{
	cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
	thread t(func);

	t.join();
	return 0;
}

this_thread命名空间中还提供了以下三个函数:

函数名		功能
yield		当前线程“放弃”执行,让操作系统调度另一线程继续执行
sleep_until	让当前线程休眠到一个具体时间点
sleep_for	让当前线程休眠一个时间段

线程函数的参数问题

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:

void add(int& num)
{
	num++;
}
int main()
{
	int num = 0;
	thread t(add, num);
	t.join();

	cout << num << endl; //0
	return 0;
}

如果要通过线程函数的形参改变外部的实参,可以参考以下三种方式:

方式一:借助std::ref函数

当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。比如:

void add(int& num)
{
	num++;
}
int main()
{
	int num = 0;
	thread t(add, ref(num));
	t.join();

	cout << num << endl; //1
	return 0;
}

方式二:地址的拷贝

将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。比如:

void add(int* num)
{
	(*num)++;
}
int main()
{
	int num = 0;
	thread t(add, &num);
	t.join();

	cout << num << endl; //1
	return 0;
}

方式三:借助lambda表达式

将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。比如:

int main()
{
	int num = 0;
	thread t([&num]{num++; });
	t.join();

	cout << num << endl; //1
	return 0;
}

join与detach

启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了如下两种回收线程资源的方式:

join方式

主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join函数就会自动清理线程相关的资源。

join函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join,否则程序会崩溃。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();
	t.join(); //程序崩溃
	return 0;
}

但如果一个线程对象join后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();

	t = thread(func, 30);
	t.join();
	return 0;
}

但采用join的方式结束线程,在某些场景下也可能会出现问题。比如在该线程被join之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
bool DoSomething()
{
	return false;
}
int main()
{
	thread t(func, 20);

	//...
	if (!DoSomething())
		return -1;
	//...

	t.join(); //不会被执行
	return 0;
}

因此采用join方式结束线程时,join的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。比如:

class myThread
{
public:
	myThread(thread& t)
		:_t(t)
	{}
	~myThread()
	{
		if (_t.joinable())
			_t.join();
	}
	//防拷贝
	myThread(myThread const&) = delete;
	myThread& operator=(const myThread&) = delete;
private:
	thread& _t;
};

使用方式如下:
每当创建一个线程对象后,就用myThread类对其进行封装产生一个myThread对象。
当myThread对象生命周期结束时就会调用析构函数,在析构中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用join对其该线程进行等待。

例如刚才的代码中,使用myThread类对线程对象进行封装后,就能保证线程一定会被join。

int main()
{
	thread t(func, 20);
	myThread mt(t); //使用myThread对线程对象进行封装

	//...
	if (!DoSomething())
		return -1;
	//...

	t.join();
	return 0;
}

detach方式

主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。

使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。
否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)。

互斥量库(mutex)

mutex的种类

四种互斥量

在C++11中,mutex中总共包了四种互斥量:

1、std::mute
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。

mutex中常用的成员函数如下:

成员函数		功能
lock		对互斥量进行加锁
try_lock	尝试对互斥量进行加锁
unlock		对互斥量进行解锁,释放互斥量的所有权

线程函数调用lock时,可能会发生以下三种情况:

如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

线程调用try_lock时,类似也可能会发生以下三种情况:

如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
如果该互斥量已经被其他线程锁住,则try_lock调用返回false,当前的调用线程不会被阻塞。
如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

2、std::recursive_mutex
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。

如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。

除此之外,recursive_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex大致相同。

3、std::timed_mutex
timed_mutex中提供了以下两个成员函数:

try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。
try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。

除此之外,timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同。

4、std::recursive_timed_mutex
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。

加锁示例

在没有使用互斥锁保证线程安全的情况下,让两个线程各自打印1-100的数字,就会导致控制台输出错乱。比如:

void func(int n)
{
	for (int i = 1; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t1(func, 100);
	thread t2(func, 100);

	t1.join();
	t2.join();
	return 0;
}

如果要让两个线程的输出不会相互影响,即不会让某一次输出中途被另一个线程打断,那么就需要用互斥锁对打印过程进行保护。

这里加锁的方式有两种,一种是在for循环体内进行加锁,一种是在for循环体外进行加锁。比如:

void func(int n, mutex& mtx)
{
	mtx.lock(); //for循环体外加锁
	for (int i = 1; i <= n; i++)
	{
		//mtx.lock(); //for循环体内加锁
		cout << i << endl;
		//mtx.unlock();
	}
	mtx.unlock();
}
int main()
{
	mutex mtx;
	thread t1(func, 100, ref(mtx));
	thread t2(func, 100, ref(mtx));

	t1.join();
	t2.join();
	return 0;
}

说明一下:

此处在for循环体外加锁比在for循环体内加锁更高效,因为在for循环体内加锁会导致线程打印数字时频繁进行加锁解锁操作,而如果在for循环体外加锁,那么这两个线程只需要在开始打印1之前进行一次加锁,在打印完100后进行一次解锁就行了。
在for循环体外加锁也就意味着两个线程的打印过程变成了串行的,即一个线程打印完1-100后另一个线程再打印,但这时打印效率提高了,因为避免了这两个线程间的频繁切换。
为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用。
此外,也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁。

经验分享:

在项目中实际不太建议定义全局变量,因为全局变量如果定义在头文件中,当这个头文件被多个源文件包含时,在这多个源文件中都会对这个全局变量进行定义,这时就会导致变量重定义,但如果将全局变量定义为静态,那这个全局变量就只在当前文件可见。
如果确实有一些变量需要在多个文件中使用,那么一般建议将这些变量封装到一个类当中,然后将这个类设计成单例模式,当需要使用这些变量时就通过这个单例对象去访问即可。

lock_guard和unique_lock

使用互斥锁时可能出现的问题

使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。比如:

mutex mtx;
void func()
{
	mtx.lock();
	//...
	FILE* fout = fopen("data.txt", "r");
	if (fout == nullptr)
	{
		//...
		return; //中途返回(未解锁)
	}
	//...
	mtx.unlock();
}
int main()
{
	func();
	return 0;
}

因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。

因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。

lock_guard

lock_guard是C++11中的一个模板类,其定义如下:

template <class Mutex>
class lock_guard;

lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。

在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。

通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。比如:

mutex mtx;
void func()
{
	lock_guard<mutex> lg(mtx); //调用构造函数加锁
	//...
	FILE* fout = fopen("data.txt", "r");
	if (fout == nullptr)
	{
		//...
		return; //调用析构函数解锁
	}
	//...
} //调用析构函数解锁
int main()
{
	func();
	return 0;
}

从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。

如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期。比如:

mutex mtx;
void func()
{
	//...
	//匿名局部域
	{
		lock_guard<mutex> lg(mtx); //调用构造函数加锁
		FILE* fout = fopen("data.txt", "r");
		if (fout == nullptr)
		{
			//...
			return; //调用析构函数解锁
		}
	} //调用析构函数解锁
	//...
}
int main()
{
	func();
	return 0;
}

模拟实现lock_guard

模拟实现lock_guard类的步骤如下:

lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
lock_guard的析构函数中调用互斥锁的unlock进行解锁。
需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。

代码如下:

namespace sherry
{
	template<class Mutex>
	class lock_guard
	{
	public:
		lock_guard(Mutex& mtx)
			:_mtx(mtx)
		{
			mtx.lock(); //加锁
		}
		~lock_guard()
		{
			mtx.unlock(); //解锁
		}
		lock_guard(const lock_guard&) = delete;
		lock_guard& operator=(const lock_guard&) = delete;
	private:
		Mutex& _mtx;
	};
}

unique_lock

但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。

unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。

但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

比如如下场景就适合使用unique_lock:

要用互斥锁保护函数1的大部分代码,但是中间有一小块代码调用了函数2,而调用函数2时不需要用函数1中的互斥锁进行保护,函数2内部的代码由其他互斥锁进行保护。
因此在调用函数2之前需要对当前互斥锁进行解锁,当函数2调用返回后再进行加锁,这样当调用函数2时其他线程调用函数1就能够获取到这个锁。

如下图:
在这里插入图片描述

原子性操作库(atomic)

线程安全问题

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:

void func(int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	int n = 0;
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

上述代码中分别让两个线程对同一个变量n进行了100000次++操作,理论上最终n的值应该是200000,但最终打印出n的值却是小于200000的。

根本原因就是++操作并不是一个原子操作,该操作分为三步:

load:将共享变量n从内存加载到寄存器中。
update:更新寄存器里面的值,执行+1操作。
store:将新值从寄存器写回共享变量n的内存地址。

++操作对应的汇编代码如下:
在这里插入图片描述

因此可能当线程1刚将n的值加载到寄存器中就被切走了,也就是只完成了++操作的第一步,而线程2可能顺利完成了一次完整的++操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量n进行了一次++操作,但最终n的值却只被++了一次。

加锁解决线程安全问题

C++98中对于这里出现的线程安全的问题,会选择对共享修改的数据进行加锁保护。比如:

void func(int& n, int times, mutex& mtx)
{
	mtx.lock();
	for (int i = 0; i < times; i++)
	{
		//mtx.lock();
		n++;
		//mtx.unlock();
	}
	mtx.unlock();
}
int main()
{
	int n = 0;
	int times = 100000; //每个线程对n++的次数
	mutex mtx;
	thread t1(func, ref(n), times, ref(mtx));
	thread t2(func, ref(n), times, ref(mtx));

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

这里可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行,而且如果锁控制得不好,还容易造成死锁。

原子类解决线程安全问题

C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。如下:

原子类型名称		对应的内置类型名称
atomic_bool		bool
atomic_char		char
atomic_schar	signed char
atomic_uchar	unsigned char
atomic_int		int
atomic_uint		unsigned int
atomic_short	short
atomic_ushort	unsigned short
atomic_long		long
atomic_ulong	unsigned long
atomic_llong	long long
atomic_ullong	unsigned long long
atomic_char16_t	char16_t
atomic_char32_t	char32_t
atomic_wchar_t	wchar_t

注意: 需要用大括号对原子类型的变量进行初始化。

程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量互斥访问。比如刚才的代码可以改为:

void func(atomic_int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic_int n = { 0 };
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

除此之外,也可以使用atomic类模板定义出任意原子类型。比如上述代码还可以改为:

void func(atomic<int>& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic<int> n = 0;
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

说明一下:

原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
原子类型不仅仅支持原子的++操作,还支持原子的–、加一个值、减一个值、与、或、异或操作。

条件变量库(condition_variable)

condition_variable中提供的成员函数,可分为wait系列和notify系列两类。

wait系列成员函数

wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait、wait_for和wait_until。

下面先以wait为例进行介绍,wait函数提供了两个不同版本的接口:

//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);

函数说明:

调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

为什么调用wait系列函数时需要传入一个互斥锁?

因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。
因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。

wait_for和wait_until函数的使用方式与wait函数类似:

wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,notify系列成员函数如果超过这个时间点则线程被自动唤醒。
线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。

注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。

notify系列成员函数

notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。

notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。
notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。

实现两个线程交替打印1-100

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。

该题目主要考察的就是线程的同步和互斥。

互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护。
同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印。

但如果只有同步和互斥是无法满足题目要求的。

首先,我们无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的。
此外,有可能会出现某个线程连续多次打印的情况,比如线程1先创建并打印了一个数字,当线程1准备打印第二个数字的时候线程2可能还没有创建出来,或是线程2还没有在互斥锁上进行等待,这时线程1就会再次获取到锁进行打印。

鉴于此,这里还需要定义一个flag变量,该变量的初始值设置为true。

假设让线程1打印奇数,线程2打印偶数。那么就让线程1调用wait函数阻塞等待时,传入的可调用对象返回flag的值,而让线程2调用wait函数阻塞等待时,传入的可调用对象返回!flag的值。
由于flag的初始值是true,就算线程2先获取到互斥锁也不能进行打印,因为最开始线程2调用wait函数时,会因为可调用对象的返回值为false而被阻塞,这就保证了线程1一定先进行打印。
为了让两个线程交替进行打印,因此两个线程每次打印后都需要更改flag的值,线程1打印完后将flag的值改为false并唤醒线程2,这时线程2被唤醒时其可调用对象的返回值就变成了true,这时线程2就可以进行打印了。
当线程2打印完后再将flag的值改为true并唤醒线程1,这时线程1就又可以打印了,就算线程2想要连续打印也不行,因为如果线程1不打印,那么线程2的可调用对象的返回值就一直为false,对于线程1也是一样的道理。

代码如下:

int main()
{
	int n = 100;
	mutex mtx;
	condition_variable cv;
	bool flag = true;
	//奇数
	thread t1([&]{
		int i = 1;
		while (i <= 100)
		{
			unique_lock<mutex> ul(mtx);
			cv.wait(ul, [&flag]()->bool{return flag; }); //等待条件变量满足
			cout << this_thread::get_id() << ":" << i << endl;
			i += 2;
			flag = false;
			cv.notify_one(); //唤醒条件变量下等待的一个线程
		}
	});
	//偶数
	thread t2([&]{
		int j = 2;
		while (j <= 100)
		{
			unique_lock<mutex> ul(mtx);
			cv.wait(ul, [&flag]()->bool{return !flag; }); //等待条件变量满足
			cout << this_thread::get_id() << ":" << j << endl;
			j += 2;
			flag = true;
			cv.notify_one(); //唤醒条件变量下等待的一个线程
		}
	});

	t1.join();
	t2.join();
	return 0;
}

总结:

今天我们学习了C++11中的线程库,了解了一些有关的底层原理。接下来,我们将继续进行C++11的学习。希望我的文章和讲解能对大家的学习提供一些帮助。

当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~

在这里插入图片描述

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

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

相关文章

基于SSM的视频播放系统的设计与实现

末尾获取源码 开发语言&#xff1a;Java Java开发工具&#xff1a;JDK1.8 后端框架&#xff1a;SSM 前端&#xff1a;Vue 数据库&#xff1a;MySQL5.7和Navicat管理工具结合 服务器&#xff1a;Tomcat8.5 开发软件&#xff1a;IDEA / Eclipse 是否Maven项目&#xff1a;是 目录…

网络库OKHttp(1)流程+拦截器

序、慢慢来才是最快的方法。 背景 OkHttp 是一套处理 HTTP 网络请求的依赖库&#xff0c;由 Square 公司设计研发并开源&#xff0c;目前可以在 Java 和 Kotlin 中使用。对于 Android App 来说&#xff0c;OkHttp 现在几乎已经占据了所有的网络请求操作。 OKHttp源码官网 版…

JVM垃圾回收算法介绍

堆的分代和区域 &#xff08;年轻代&#xff09;Young Generation&#xff08;eden、s0、s1 space&#xff09; Minor GC &#xff08;老年代&#xff09;Old Generation &#xff08;Tenured space&#xff09; Major GC|| Full GC &#xff08;永久代&#xff09;Permanent…

Qt之自定义插件(单控件,Qt设计师中使用)

文章目录 步骤1.选择项目类型2.设置项目名称3.选择合适的构建套件4.根据实际情况选择插件控件列表6.控件类生成&#xff08;默认勾选项&#xff09;7.构建生成项目及生成库位置&#xff08;默认&#xff09;8.库文件拷贝9.重启Qt查看效果 步骤 1.选择项目类型 如图选择‘其他…

oracle库中数据利用datax工具同步至mysql库

查看oracle版本 $sqlplus aaa/aaaa192.168.1.1/lcfaSQL*Plus: Release 19.0.0.0.0 - Production on Tue Oct 17 15:56:46 2023 Version 19.15.0.0.0Copyright (c) 1982, 2022, Oracle. All rights reserved.Last Successful login time: Tue Oct 17 2023 15:56:03 08:00Conne…

NSS [NISACTF 2022]easyssrf

NSS [NISACTF 2022]easyssrf 先看题目&#xff0c;给了一个输入框 看这提示就知道不是curl了&#xff0c;先file协议读一下flag&#xff0c;file:///flag 不能直接读flag&#xff0c;读个提示文件file:///fl4g 访问一下 <?phphighlight_file(__FILE__); error_reporting(0…

NSS [GWCTF 2019]枯燥的抽奖

NSS [GWCTF 2019]枯燥的抽奖 开题让我猜字符串&#xff0c;这种题目肯定不是猜&#xff0c;应该是类似于php伪随机数。 dirsearch扫他一下。 访问/check.php得到源码。 分析一下代码。 通过PHP伪随机数从字符库$str_long1中选取20个字符组成字符串&#xff0c;返回给我们前十…

EasyCVR视频汇聚平台显示有视频流但无法播放是什么原因?该如何解决?

视频汇聚/视频云存储/集中存储/视频监控管理平台EasyCVR能在复杂的网络环境中&#xff0c;将分散的各类视频资源进行统一汇聚、整合、集中管理&#xff0c;实现视频资源的鉴权管理、按需调阅、全网分发、云存储、智能分析等&#xff0c;视频智能分析平台EasyCVR融合性强、开放度…

BI零售数据分析,当代零售企业的核心竞争力

在数字化转型中&#xff0c;BI智能零售数据分析成为了极其重要的核心竞争力之一。通过对大数据的采集和分析&#xff0c;零售企业可以更好地了解消费者的需求和行为模式&#xff0c;从而做出更准确的决策。例如&#xff0c;通过分析消费者的购物历史、浏览记录等数据&#xff0…

五、WebGPU Vertex Buffers 顶点缓冲区

五、WebGPU Vertex Buffers 顶点缓冲区 在上一篇文章中&#xff0c;我们将顶点数据放入存储缓冲区中&#xff0c;并使用内置的vertex_index对其进行索引。虽然这种技术越来越受欢迎&#xff0c;但向顶点着色器提供顶点数据的传统方式是通过顶点缓冲和属性。 顶点缓冲区就像任…

[Java]0.1+0.2不等于0.3 !!一分钱问题与解决方案

一、原因 原因很简单&#xff0c;计算机存储和计算数组都是用二进制&#xff0c; 而大部分小数转二进制的时候&#xff0c;就丢失精度了。 0.1、0.2、0.3这些小数在二进制里都是循环小数&#xff0c;计算机不可能存储无限循环小数&#xff0c;所以只能截取一部分&#xff0c;导…

Linux网络-UDP/TCP协议详解

Linux网络-UDP/TCP协议详解 2023/10/17 14:32:49 Linux网络-UDP/TCP协议详解 零、前言一、UDP协议二、TCP协议 1、应答机制2、序号机制3、超时重传机制4、连接管理机制 三次握手四次挥手5、理解CLOSE_WAIT状态6、理解TIME_WAIT状态7、流量控制8、滑动窗口 丢包问题9、拥塞控制…

如何使用前端框架(React、Angular、Vue.js等)?该如何选择?

聚沙成塔每天进步一点点 ⭐ 专栏简介 前端入门之旅&#xff1a;探索Web开发的奇妙世界 欢迎来到前端入门之旅&#xff01;感兴趣的可以订阅本专栏哦&#xff01;这个专栏是为那些对Web开发感兴趣、刚刚踏入前端领域的朋友们量身打造的。无论你是完全的新手还是有一些基础的开发…

“小”Bug,大能量

文章目录 简介BUG集&#xff08;持续更新&#xff09;依赖版本不一致配置错误循环依赖内存泄漏并发问题空指针异常依赖版本冲突 总结 简介 在写代码的时候&#xff0c;遇到了一些bug&#xff0c;在当下怎么检查都查不出问题出现在哪&#xff0c;等过几天后突然发现困扰自己的问…

快速自动化处理JavaScript渲染页面的方法

目录 一、使用无头浏览器 二、使用JavaScript渲染引擎 三、使用前端框架工具 随着互联网技术的不断发展&#xff0c;JavaScript已经成为Web开发中不可或缺的一部分。然而&#xff0c;在自动化处理JavaScript渲染页面方面&#xff0c;却常常让开发者感到头疼。本文将介绍一些快…

数仓建设(三)

4) 累积快照事实表 多个业务过程联合分析而构建的事实表&#xff0c;如采购单的流转环节。用于分析事件时间和时间之间的间隔周期。少量的且当前事务型不支持的&#xff0c;如关闭、发货等相关的统计。 4. DWS公共汇总层设计规范 数据仓库的性能是数据仓库建设是否成功的重要标…

从头开始机器学习:逻辑回归

一、说明 本篇实现线性回归的先决知识是&#xff1a;基本线性代数&#xff0c;微积分&#xff08;偏导数&#xff09;、梯度和、Python &#xff08;NumPy&#xff09;&#xff1b;从线性方程入手&#xff0c;逐渐理解线性回归预测问题。 二、逻辑回归简介 我们将以我们在线性回…

Memory Analyzer分析内存溢出

一、下载和安装 访问Eclipse MAT官网&#xff0c;下载适用于你操作系统的最新版本的MAT&#xff0c;并进行安装。 二、使用 2.1 导出Heap Dump文件 2.1.1 使用jmap命令导出Heap Dump文件 可以在终端或命令提示符中执行以下命令: jmap -dump:formatb,file/path/to/heapdum…

JSONObject和JSONArray区别及注意事项

1、JSONObject和JSONArray的数据表示形式 JSONObject的数据是用 { } 来表示的&#xff0c; 例如&#xff1a; {"name":"John","age":30,"city":"New York"}而JSONArray&#xff0c;顾名思义是由JSONObject构成的数组&…

NSS [BJDCTF 2020]easy_md5

NSS [BJDCTF 2020]easy_md5 先看题目&#xff0c;给了一个输入框 翻阅了源码没发现什么可疑点 扫一下试试&#xff0c;也没东西 抓个包试试&#xff0c;在响应头发现了hint 那就是奇妙的md5了&#xff0c;输入ffifdyop 原理&#xff1a; ffifdyop的MD5加密结果是276f722736c…