多线程学习笔记"/>
C++多线程学习笔记
1. thread — 线程篇
所需头文件:<thread>
1.1 构造函数
// 1 默认构造函数
thread() noexcept;
// 2 移动构造函数,把other的所有权转移给新的thread对象,之后 other 不再表示执行线程。
thread( thread&& other ) noexcept;
// 3 f参数可选:普通函数,类成员函数,匿名函数,仿函数
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
// 4 使用 =delete 显示删除拷贝构造, 不允许线程对象之间的拷贝
thread( const thread& ) = delete;
第三种创建: 创建线程对象,并在该线程中执行函数 f 中的业务逻辑,args 是要传递给函数 f 的参数
任务函数 f 的可选类型有很多,具体如下:
- 普通函数,类成员函数,匿名函数,仿函数(这些都是可调用对象类型)
- 可以是可调用对象包装器类型,也可以是使用绑定器绑定之后得到的类型(仿函数)
f 一般返回值指定为 void
,因为子线程在调用这个函数的时候不会处理其返回值。
1.2 公共成员函数
get_id()
应用程序启动之后默认只有一个线程,这个线程一般称之为主线程
或父线程
,通过线程类创建出的线程一般称之为子线程
,每个被创建出的线程实例都对应一个线程ID,这个ID是唯一的,可以通过这个ID来区分和识别各个已经存在的线程实例。
调用命名空间std::this_thread
中的get_id()
方法可以得到当前线程的线程ID
,在主函数中调用即可得到主线程ID
,在线程中调用即可得到子线程ID
。
void func(){cout << "子线程: " << this_thread::get_id() << endl;
}
// main()
cout << "主线程: " << this_thread::get_id() << endl;
thread t(func); // 创建子线程
t.get_id(); // 在主函数中获得子线程ID
t.join();
当启动了一个线程(创建了一个thread对象)之后,在这个线程结束的时候(std::terminate()
),我们如何去回收线程所使用的资源呢?thread库给我们两种选择:
- 加入式(join())
- 分离式(detach())
join()
join()
字面意思是连接一个线程,意味着主动地等待线程的终止(线程阻塞)。在某个线程中通过子线程对象调用join()
函数,调用这个函数的线程被阻塞,但是子线程对象中的任务函数会继续执行,当任务执行完毕之后,join()
会清理当前子线程中的相关资源然后返回,同时,调用该函数的线程解除阻塞继续向下执行。join()
函数在哪个线程中被执行,那么哪个线程就被阻塞。
// 子线程
void func(){cout << "子线程 " << endl;
}
// 主线程
cout << "主线程" << endl;
thread t(func); // 创建子线程
t.join(); // main线程中调用线程 t 的join函数,main线程随之被阻塞,不会向下执行
int x = 1;
调用 join()
有两种情况:
- 如果任务函数
func()
还没执行完毕,主线程阻塞,直到子线程执行完毕,主线程解除阻塞,继续向下运行 - 如果任务函数
func()
已经执行完毕,主线程不会阻塞,继续向下运行
总的来说,t.join()
语句使得只有在子线程 t
完成执行之后,才能执行 int x = 1;
语句。
线程执行完毕后,join()
会清理(回收)当前子线程的相关资源
detach()
detach()
函数的作用是进行线程分离,分离主线程和创建出的子线程。在线程分离之后,主线程退出也会一并销毁创建出的所有子线程,在主线程退出之前,它可以脱离主线程继续独立的运行,任务执行完毕之后,这个子线程会自动释放自己占用的系统资源。
注意事项:线程分离函数detach()
不会阻塞线程,子线程和主线程分离之后,在主线程中就不能再对这个子线程做任何控制了,比如调用get_id()
获取子线程的线程ID。
joinable()
joinable()
函数用于判断主线程和子线程是否处理关联(连接)状态,一般情况下,二者之间的关系处于关联状态,该函数返回一个布尔类型,有连接关系返回值为 true
,否则返回 false
。
实例:
void foo(){cout << "thread starts" << endl;
}thread t; // 在创建的子线程对象的时候,如果没有指定任务函数,那么子线程不会启动,主线程和这个子线程也不会进行连接
cout << "before starting, joinable: " << t.joinable() << endl; // 0t = thread(foo); // 指定了任务函数,子线程启动并执行任务,主线程和这个子线程自动连接成功
cout << "after starting, joinable: " << t.joinable() << endl;// 1t.join();// 线程t任务处理完毕后,这时join()会清理(回收)当前子线程的相关资源,所以这个子线程和主线程的连接也就断开了
// 因此,调用join()之后再调用joinable()会返回false。
cout << "after joining, joinable: " << t.joinable() << endl;// 0thread t1(foo);// 在创建的子线程对象的时候,如果指定了任务函数,子线程启动并执行任务,主线程和这个子线程自动连接成功
cout << "after starting, joinable: " << t1.joinable() << endl;// 1
t1.detach();// 子线程调用了detach()函数之后,父子线程分离,同时二者的连接断开,调用joinable()返回false
cout << "after detaching, joinable: " << t1.joinable() << endl;// 0
operator=
线程中的资源是不能被复制的
,因此通过=操作符
进行赋值操作最终并不会得到两个完全相同的对象。
// move (1)
thread& operator= (thread&& other) noexcept;
// copy [deleted] (2)
thread& operator= (const other&) = delete;
实例:
void foo(){cout << "thread starts" << endl;
}thread t1(foo); // 因为下边有资源所有权转移,这里的t1线程并不会执行
t1.join(); // error, 同一份资源不能回收两次,会抛出异常
thread t2(move(t1)); // 将线程t1的资源所有权的转移给t2
t2.join(); // t2线程执行完进行,join() 会进行资源回收
1.3 静态函数(获取CPU核心数)
thread
线程类还提供了一个静态方法,用于获取当前计算机的CPU核心数,根据这个结果在程序中创建出数量相等的线程,每个线程独自占有一个CPU核心,这些线程就不用分时复用CPU时间片,此时程序的并发效率是最高的
。
int num = thread::hardware_concurrency();
cout << "CPU number: " << num << endl;
1.4 命名空间 - this_thread
get_id()
关于命名空间里的 get_id()
前别已经涉及过,这里就不再赘述。
简答来说直接调用可以得到当前线程的线程ID
,this_thread::get_id()
sleep_for()
进程创建完成后有 5 种状态,同样地线程被创建后也有这五种状态:创建态,就绪态,运行态,阻塞态(挂起态),退出态(终止态)
,关于状态之间的转换是一样的。
命名空间this_thread
中提供了一个休眠函数sleep_for()
,调用这个函数的线程会马上从运行态
变成阻塞态
并在这种状态下休眠一定的时长,因为阻塞态的线程已经让出了CPU资源,代码也不会被执行,所以线程休眠过程中对CPU来说没有任何负担。
参数需要指定一个休眠时长,一般配合<chrono>
库使用,时间长度 duration
类型
#include <chrono>
#include <thread>
void func(){this_thread::sleep_for(chrono::seconds(1)); // 当前线程进入阻塞态1s
}
thread t(func);
t.join();
sleep_until()
指定线程到某一个指定的时间点time_point
类型,之后解除阻塞
作用其实和sleep_for()
差不多
void func(){auto now = chrono::system_clock::now();// 获取当前系统时间点chrono::seconds sec(2);// 时间间隔为2sthis_thread::sleep_until(now + sec);// 当前时间点之后休眠两秒
}
thread t(func);
t.join();
yeild()
调用yeild
函数会使处于运行态的线程会主动让出自己已经抢到的CPU时间片,最终变为就绪态
使用这个函数的时候需要注意一点,线程调用了yield()
之后会主动放弃CPU资源,但是这个变为就绪态的线程会马上参与到下一轮CPU的抢夺战中,不排除它能继续抢到CPU时间片的情况,这是概率问题。
结论:
std::this_thread::yield()
的目的是避免一个线程长时间占用CPU资源,从而导致多线程处理性能下降std::this_thread::yield()
是让当前线程主动放弃了当前自己抢到的CPU资源,但是在下一轮还会继续抢
2. mutex — 线程同步篇
2.1 call_once
在某些特定情况下,某些函数只能在多线程环境下调用一次,比如:要初始化某个对象,而这个对象只能被初始化一次,就可以使用std::call_once()
来保证函数在多线程环境下只能被调用一次。使用call_once()
的时候,需要一个once_flag
作为call_once()
的传入参数。
once_flag g_flag; // 全局定义
std::call_once(once_flag flag, 回调函数,回调函数的参数);
多线程操作过程中,std::call_once()
内部的回调函数只会被执行一次
#include <mutex>once_flag g_flag;
void do_once(int a){cout << "age: " << a << endl;
}
void do_something(int age){static int num = 1;call_once(g_flag, do_once, 19);cout << "do_something() function num = " << num++ << endl;
}thread t1(do_something, 20);
thread t2(do_something, 19);
t1.join();
t2.join();
虽然运行的两个线程中都执行了任务函数do_something()
,但是call_once()
中指定的回调函数只被执行了一次。
2.1 利用互斥锁进行线程同步
进行多线程编程,如果多个线程需要对同一块内存进行操作,比如:同时读、同时写、同时读写
对于后两种情况来说,如果不做任何的人为干涉就会出现各种各样的错误数据。这是因为线程在运行的时候需要先得到CPU时间片,时间片用完之后需要放弃已获得的CPU资源,这种并发执行是 “执行–简短–执行” 的间断性活动,并且由于多线程可以共享变量,这样失去了封闭性,导致运行的结果具有不可再现性。
解决多线程数据混乱的方案就是进行线程同步,最常用的就是互斥锁,在C++11中一共提供了四种互斥锁:
std::mutex
:独占的互斥锁,不能递归使用std::timed_mutex
:带超时的独占互斥锁,不能递归使用std::recursive_mutex
:递归互斥锁,不带超时功能std::recursive_timed_mutex
:带超时的递归互斥锁
互斥锁在有些资料中也被称之为互斥量,二者是一个东西。
std::mutex(普通互斥锁)
lock()
lock()
函数用于给临界区加锁,并且只能有一个线程获得锁的所有权,它有阻塞线程的作用。
独占互斥锁对象有两种状态:锁定和未锁定。如果互斥锁是打开的,调用lock()
函数的线程会得到互斥锁的所有权,并将其上锁,其它线程再调用该函数的时候由于得不到互斥锁的所有权,就会被lock()
函数阻塞。当拥有互斥锁所有权的线程将互斥锁解锁,此时被lock()
阻塞的线程解除阻塞,抢到互斥锁所有权的线程加锁并继续运行,没抢到互斥锁所有权的线程继续阻塞。
try_lock()
除了使用lock()
还可以使用try_lock()
获取互斥锁的所有权并对互斥锁加锁,二者的区别在于try_lock()
不会阻塞线程,lock()
会阻塞线程:
- 如果互斥锁是未锁定状态,得到了互斥锁所有权并加锁成功,函数返回 true
- 如果互斥锁是锁定状态,无法得到互斥锁所有权加锁失败,函数返回 false
try_lock()
如果被调用时没有获得锁则直接返回 false
,一个尝试动作,返回 false
不会阻塞线程。
unlock()
当互斥锁被锁定之后可以通过unlock()
进行解锁,但是需要注意的是只有拥有互斥锁所有权的线程也就是对互斥锁上锁的线程才能将其解锁,其它线程是没有权限做这件事情的
。
当线程对互斥锁对象加锁,并且执行完临界区代码之后,一定要使用这个线程对互斥锁解锁,否则最终会造成线程的死锁
。死锁之后当前应用程序中的所有线程都会被阻塞,并且阻塞无法解除,应用程序也无法继续运行。
通过介绍以上三个函数,使用互斥锁进行线程同步的大致思路差不多就能搞清楚了,主要分为以下几步:
- 找到多个线程操作的共享资源(全局变量、堆内存、类成员变量等),也可以称之为
临界资源
- 找到和共享资源有关的上下文代码,也就是
临界区
(下图中的黄色代码部分) - 在临界区的上边调用互斥锁类的
lock()
方法 - 在临界区的下边调用互斥锁的
unlock()
方法 - 线程同步的目的是让多线程按照顺序依次执行临界区代码,这样做线程对共享资源的访问就从并行访问变为了线性访问,访问效率降低了,但是保证了数据的正确性。
线程同步实例
int g_num = 0; // 为 g_num_mutex 所保护
mutex g_num_mutex;void slow_increment(int id){for (int i = 0; i < 3; ++i){g_num_mutex.lock(); // 临界区的上边加锁++g_num;cout << id << " => " << g_num << endl;g_num_mutex.unlock(); // // 临界区的下边解锁this_thread::sleep_for(chrono::seconds(1));}
}thread t1(slow_increment, 0);
thread t2(slow_increment, 1);
t1.join();
t2.join();
运行方式为:每次抢到mutex
的线程,执行完一次for就会进行unlock()
另外需要注意一点:
- 在所有线程的任务函数执行完毕之前,互斥锁对象是不能被析构的,一定要在程序中保证这个对象的可用性。
- 互斥锁的个数和共享资源的个数相等,也就是说
每一个共享资源都应该对应一个互斥锁对象
。互斥锁对象的个数和线程的个数没有关系
。
std::lock_guard() (简化互斥锁写法)
lock_guard
是C++11新增的一个模板类,使用这个类,可以简化互斥锁lock()
和unlock()
的写法,同时也更安全。lock_guard()
在使用上面提供的这个构造函数构造对象时,会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁,从而保证了互斥量的正确操作,避免忘记unlock()
操作而导致线程死锁。lock_guard()
使用了RAII技术,就是在类构造函数中分配资源,在析构函数中释放资源,保证资源出了作用域就释放。
lock_guard<mutex> lock(g_num_mutex); // 用已经定义好的锁初始化类模板
int g_num = 0; // 为 g_num_mutex 所保护
mutex g_num_mutex;void slow_increment(int id){for (int i = 0; i < 3; ++i) {// 使用哨兵锁管理互斥锁lock_guard<mutex> lock(g_num_mutex); ++g_num;cout << id << " => " << g_num << endl;this_thread::sleep_for(chrono::seconds(1));}
}thread t1(slow_increment, 0);
thread t2(slow_increment, 1);
t1.join();
t2.join();
这种运行方式为:先抢到互斥锁的线程,执行完整个三次for循环,才会解锁。
这种方式看起来方便,但是也有弊端,在上面的示例程序中整个for循环的体都被当做了临界区,多个线程是线性的执行临界区代码的
,因此临界区越大程序效率越低。
std::recursive_mutex(允许互斥锁递归)
递归互斥锁std::recursive_mutex
允许同一线程多次获得互斥锁,可以用来解决同一线程需要多次获取互斥量时死锁的问题,在下面的例子中使用独占非递归互斥量会发生死锁:
struct Calculate{Calculate() : m_i(6) {}void mul(int x){//lock_guard<mutex> locker(m_mutex);lock_guard<recursive_mutex> locker(m_mutex);m_i *= x;}void div(int x){//lock_guard<mutex> locker(m_mutex);lock_guard<recursive_mutex> locker(m_mutex);m_i /= x;}void both(int x, int y){//lock_guard<mutex> locker(m_mutex);lock_guard<recursive_mutex> locker(m_mutex);mul(x);div(y);}int m_i;//mutex m_mutex;recursive_mutex m_mutex;
};Calculate cal;
cal.both(6, 3); // 调用后就会发生死锁
在both()
中已经对互斥锁加锁了,继续调用mult()
函数,已经得到互斥锁所有权的线程再次获取这个互斥锁的所有权就会造成死锁(在C++中程序会异常退出,使用C库函数会导致这个互斥锁永远无法被解锁,最终阻塞所有的线程)。要解决这个死锁的问题,一个简单的办法就是使用递归互斥锁std::recursive_mutex
,它允许一个线程多次获得互斥锁的所有权。
虽然递归互斥锁可以解决同一个互斥锁频繁获取互斥锁资源的问题,但是还是建议少用,主要原因如下:
- 使用递归互斥锁的场景往往都是可以简化的,使用递归互斥锁很容易放纵复杂逻辑的产生,从而导致bug的产生
- 递归互斥锁比非递归互斥锁效率要低一些。
- 递归互斥锁虽然允许同一个线程多次获得同一个互斥锁的所有权,但最大次数并未具体说明,一旦超过一定的次数,就会抛出
std::system
错误。
std::timed_mutex (不像lock一样一直阻塞)
std::timed_mutex
是超时独占互斥锁,主要是在获取互斥锁资源时增加了超时等待功能,因为不知道获取锁资源需要等待多长时间,为了保证不一直等待下去,设置了一个超时时长,超时后线程就可以解除阻塞去做其他事情了。
std::timed_mutex
比std::_mutex
多了两个成员函数:try_lock_for()
和try_lock_until()
。两个函数是对 lock()
函数的延申,返回true
时就代表已经获取到互斥锁资源。
try_lock_for()
函数是当线程获取不到互斥锁资源的时候,在给定的时间长度内,允许线程为尝试获得资源而阻塞。参数对应sleep_for()
try_lock_until()
函数是当线程获取不到互斥锁资源的时候,在给定的时间节点之前,允许线程为尝试获得资源而阻塞。参数对应sleep_until()
关于两个函数的返回值(bool):在未达到限定情况时,得到互斥锁的所有权之后,函数会马上解除阻塞,返回 true;如果阻塞的时长用完或者到达指定的时间点之后,函数也会解除阻塞,返回 false。
std::recursive_timed_mutex
关于递归超时互斥锁std::recursive_timed_mutex
的使用方式和std::timed_mutex
是一样的,只不过它可以允许一个线程多次获得互斥锁所有权,而std::timed_mutex
只允许线程获取一次互斥锁所有权。另外,递归超时互斥锁std::recursive_timed_mutex
也拥有和std::recursive_mutex
一样的弊端,不建议频繁使用。
更多推荐
C++多线程学习笔记
发布评论