C++——多线程编程:<thread> && <mutex>,线程与互斥

引言

C++11是一个重要的标准,它引入了许多新的特性和库,其中一个非常重要的方面是多线程编程,即让一个进程可以同时运行多个任务。C++11为多线程编程提供了一系列的库。
在本系列中,我将介绍这些库的用法和示例。在第一篇,我将介绍thread和mutex两个库,讲解如何创建和管理线程,以及如何使用互斥锁来保证临界资源的数据安全。

  • thread:提供了std::thread,用于表示和管理一个线程,可以创建、启动、终止、等待、分离等操作一个线程。提供了std::this_thread命名空间,用于访问和控制当前线程的属性和行为,例如获取线程ID、休眠、让出时间片。
  • mutex:这个库提供了一系列的互斥锁类,用于保护共享数据的访问和修改,避免数据竞争和不一致。它包括四种不同语义的互斥锁,以及两种锁的管理器。

一、thread

C++11对线程接口做了封装,使得代码在Linux和Windows下可以通用,提高了可移植性。

std::thread

std::thread用于创建一个线程,即一个执行流。各个执行流在当前进程内共享进程的代码和数据。每一个线程各自独立的数据位于进程地址空间的共享区。

thread() noexcept; // 无参构造

template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args ); // 带参构造

thread( thread&& other ) noexcept;  // 移动构造

thread( const thread& ) = delete;   // 不允许拷贝构造
  • 无参构造:构造的对象没有创建执行流,只是一个空对象。
  • 带参构造:f可以接收函数指针,仿函数,lambda对象,function对象等。args表示要一并传递的对象,是一个可变参数列表。
  • 移动构造:允许将一个thread对象把其内容移动给另一个thread对象。

thread对象不可拷贝,只可移动。因为thread删除了拷贝构造和赋值构造,所以下面的代码是错误的。

int main()
{
    std::vector<std::thread> allThread;
    std::thread t1(func1);

    allThread.push_back(t1); // Error
    return 0;
}

成员函数

函数 功能
get_id 获取该线程的id
joinable 判断该线程是否执行完毕,是:true,否:false
join 该函数调用后当前进程会阻塞等待该线程,当该线程结束后,当前线程才往后执行
detach 将当前线程和该线程分离,当前线程后续无需join该线程
swap 交换两个线程对象

使用方式

template<class T>
void func1(T& val)
{}

void func2(int& val)
{}

int main()
{
    int num = 0;
    std::thread t1; // 没有执行流,空对象
    std::thread t2(func1<int>, num); // 创建一个执行流用于执行func1函数,并把num以传值形式传递进去,线程里的num是新的。
    std::thread t3(func2, std::ref(num)); // 创建一个执行流用于执行func2函数,并且将num以引用形式传递,线程里的num是和主线程共享的。
    t1 = t3; // 移动构造,将t3对象移动到t1对象处,此时t3不再代表一个执行流。
    
    t2.detach();
    t3.join();
    return 0;
}

std::ref

引用传参的时候要使用std::ref,是因为thread构造函数中,参数列表是一个右值引用。因此C++提供了ref来包装以引用传递的值。
同样的还有std::bind的场景中,如果function对象要传入参数,且参数是引用,则需要使用ref。

void func(int& num1, int& num2, int& num3)
{
    // 在这里,num1和num2都是两个独立的变量,而num3是main中num3的引用。
}

int main()
{
    int num1, num2, num3;
    auto obj = std::bind(func, num1, num2, std::ref(num3));
    return 0;
}

在多线程中,可以使用ref来传递引用。ref只是模拟引用传递,而不是真实的引用。

this_thread

命名空间this_thread提供了一批给当前线程使用的函数。

thread::id get_id();

void yield();

void sleep_for(const chrono::duration<_Rep, _Period>& __rtime);

template<typename _Clock, typename _Duration>
void sleep_until(const chrono::time_point<_Clock, _Duration>& __atime);

get_id

调用一个thread对象的get_id可以获取该线程的ID,但是如果在函数执行流中要获取本线程的ID,可以通过this_thread这个命名空间的get_id()方法。

返回值是一个``thread::id``对象,类内有一个运算符重载函数,用于输出
friend basic_ostream<_Ch, _Tr>& operator<<(basic_ostream<_Ch, _Tr>& _Str, thread::id _Id);

在Linux下,一层层去找id的来源,最终会找到id是一个 pthread_t 类型,即thread是封装了原生接口的。
关于打印这个id,有如下方法:

一、直接用cout输出
std::cout << std::this_thread::get_id() << std::endl;

二、用stringstream接收,然后输出原生字符串。
std::ostringstream oss;
oss << std::this_thread::get_id() << std::endl;
printf("%s\n", oss.str().c_str());

yield

让当前线程放弃本次执行占用的时间片,让CPU调度其他线程。避免线程长时间占用CPU资源,导致多线程处理性能下降。
场景:当线程在等待某个事件就绪,如果一直检测,就会占用CPU时间。因此可以判断条件是否满足,如果不满足就yield。

while(true)
{
	if(事件就绪)
	{
	}
	else
		yield;
}

但是这样看起来好像并不是有多大用处。实际上让出时间片并不会让CPU占用减少多少。所以类似事件驱动型的操作,用同步的方式,如condition_variable来通知线程会更好。

sleep_for

线程与进程一样具有状态:运行态、终止态、挂起态、阻塞态。每个线程都要占用CPU资源,但是资源是有限的,为了实现并发,线程是分时复用CPU的。sleep_for是睡眠函数,可以使这个线程变为阻塞态,并休眠一定时间,且休眠过程不占用CPU的资源。参数类型是时间长度duration。

std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 睡眠200毫秒

sleep_until

与sleep_for类似,而until是休眠到一个时间点。参数类型是time_point。

auto now = std::chrono::steady_clock::now(); // 获取当前时间
auto wake = now + std::chrono::seconds(5);   // 计算唤醒时间

std::this_thread::sleep_until(wake);         // 睡眠直到唤醒

sleep都是利用操作系统的调度机制来实现睡眠。


二、mutex

多线程编程中,线程互斥是一个重要的话题。对于临界资源,需要访问控制机制来保证数据的准确性。mutex是C++11中基本的互斥量。C++11提供了4种互斥量:

  • mutex:不带超时的互斥量,不能递归加锁
  • time_mutex:带超时的独占的互斥量,不能递归加锁
  • recursive_mutex:不带超时的递归互斥量
  • recursive_timed_mutex:带超时的递归互斥量

std::mutex

mutex();                        // 无参构造
mutex( const mutex& ) = delete; // 禁止拷贝构造

与thread同样的,mutex不支持拷贝构造。此外,不支持移动构造。

成员函数

成员函数 功能
lock 上锁。如果mutex已经被其它线程上锁,那么会阻塞,直到解锁;
unlock 解锁。调用此函数会将mutex的主动权随机交给一个正在尝试上锁的线程。
try_lock 尝试上锁。如果mutex未被上锁,则将其上锁并返回true;如果mutex已被锁则返回false。

对于lock,如果线程阻塞在lock,且该线程拥有mutex的所有权,那么会造成死锁。

std::recursive_mutex

与mutex不同,recursive支持递归上锁,即对同一个互斥量上多层的锁。相应的,上多层锁,解锁的时候也要开多层的锁。

std::time_mutex

与mutex相比,time_mutex多了两个成员函数:try_lock_for和try_lock_until。
try_lock_for:允许在一段时间范围内,如果没有成功解锁即被阻塞一段时间,如果超时即返回false。而try_lock是不会阻塞的。
try_lock_until:则是允许在一个时间点之前。

std::lock_guard 和 std::unique_lock

RAII的思想是C++等语言资源管理的重要指导思想。C++引入lock_guard,像智能指针管理内存一样管理一个互斥量。在一个作用域内获得一个锁,并在出了作用域,对象析构时归还这个锁。

std::mutex myMutex;
{
    std::lock_guard<std::mutex> guard(myMutex);
}

lock_guard有一个缺陷:在作用域内定义lock_guard时会构造并加锁,直到析构时才解锁。如果作用域范围很大,那么锁的粒度就会很大。于是也引入了unique_lock。

  • unique_lock与lock_guard都能RAII地管理一个锁。前者更加灵活,提供了mutex的接口,用来当作一个普通mutex使用。
  • unique_lock在构造对象后就可以随意的加锁和解锁,不必等到析构时才解锁。而lock_guard是不支持手动解锁的。
  • unique_lock相比lock_guard,内部会维护一个锁,因此会更占资源。

mutex使用示例

void work() // 线程函数
{
	while (true)
	{
		g_mutex.lock();
		
		g_count++;
		printf("This Is Thread");std::cout << this_thread::get_id() << std::endl;
		
		g_mutex.unlock();
	}
}
要注意使用mutex的接口,lock之后必须手动unlock。不然进程会crash,提一嘴,线程收到信号,进程会被kill掉。
所以可以使用lock_guard和unique_lock。
void work() // 线程函数
{
	while (true)
	{
		std::unique_lock<std::mutex> locker(g_mutex);
		
		g_count++;
		printf("This Is Thread");std::cout << this_thread::get_id() << std::endl;
	}
}
这样就省去了lock和unlock的步骤。
要注意的是:locker构造后,在手动unlock之前,lock也会crash。
因为对一个锁重复加锁和解锁都是错误的行为。

如果运行了上面关于mutex的示例代码,你会发现运行的时候,一个线程可能会长时间占用这个锁,直到一段时间过后才会交给另一个线程。并且这个新线程同样会占用不短的时间。
这就是线程的竞争问题,如果一个线程长时间占用不到时间片,就可以称为线程饥饿,会浪费资源。我的下一篇讲condition_variable的博客将会提到如何解决这个问题。

你可能感兴趣的:(C/C++,c++,开发语言,linux)