一文搞定c++多线程

分手后的思念是犯贱 2022-12-04 09:59 259阅读 0赞

一文搞定c++多线程

c++11引入了用于多线程操作的thread类,该库移植性更高,并且使得写多线程变得简洁了一些。

多线程头文件支持

为了支持多线程操作,c++11新标准引入了一些头文件来支持多线程编程:

  • :内部声明了 std::thread 类,用于创建多线程
  • :内部声明std::atomic 和 std::atomic_flag两个类,可以利用这两个类实现原子类型的各种特性,并且声明了一些原子操作函数
  • :提供了多种互斥操作,可以显式避免数据竞争,内部包含mutex类型、lock类型以及功能函数.
  • :声明了与条件变量相关的类,包括 std::condition_variable 和 std::condition_variable_any
  • :通过特殊的provider进行数据的异步访问,实现线程间的通信,主要用于支持异步访问。

多线程示例

简单多线程

一个简单的多线程:

  1. #include <iostream>
  2. #include <thread>
  3. using namespace std;
  4. void thread_test()
  5. {
  6. std::cout << "I'm thread_test()\n";
  7. }
  8. int main()
  9. {
  10. std::thread t1(thread_test);
  11. cout << "I'm main_thread\n";
  12. //do somtthing
  13. t1.join();
  14. return 0;
  15. }

部分需要补充的点:

  • main函数构建了一个std::thread对象t1,构造的时候传递了一个函数参数,这个参数就是线程的入口函数,函数执行完了,整个线程也就执行完了,线程创建成功后,就会立即启动
  • 一旦线程开始运行, 就需要显式的决定是要等待它完成(join),或者分离它让它自行运行(detach)。注意:只需要在std::thread对象被销毁之前做出这个决定
  • 调用join(),主线程会一直阻塞着,直到子线程完成,join()函数的另一个任务是回收该线程中使用的资源,如果采用detach的话表示子线程和主线程分离,这样子线程将有操作系统管理,主线程结束后也thread对象被析构,但是该线程仍将继续执行至结束。
detach和join的区别

在任何一个时间点上,线程是可结合的(joinable),或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死;在被其他线程回收之前,它的存储器资源是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。也就是说,分离状态还是可结合状态主要决定一个线程以怎样的方式终结。

当thread::join()函数被调用后,调用它的线程会被block,直到线程的执行被完成。基本上,这是一种可以用来知道一个线程已结束的机制。当thread::join()返回时,OS的执行的线程已经完成,C++线程对象可以被销毁。

当thread::detach()函数被调用后,执行的线程从线程对象中被分离,已不再被一个线程对象所表达–这是两个独立的事情。C++线程对象可以被销毁,同时OS执行的线程可以继续,也就是说主线程结束后该线程仍然可以继续运行,因此如果不关心一个线程的结束状态,那么也可以将一个线程设置为 detached 状态,从而让操作系统在该线程结束时来回收它所占的资源。

实际上,程序员应该在thread对象执行流程到析构函数前总是要么join,要么detach一个线程。当一个程序终止时(比如main返回),剩下的在后台的detached线程执行不会再等待;相反它们的执行会被挂起并且它们的本地线程对象会被销毁。通常情况下使用join即可。

否则会有问题:

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. void test_thread()
  6. {
  7. cout << "Another thread is working!\n" ;
  8. }
  9. int main()
  10. {
  11. thread task(test_thread);
  12. cout << "Main thread is working!\n";
  13. system("pause");
  14. }

这是一个很简单的多线程程序,但是并没有对task线程进行join或者detach,运行结果:

  1. PS D:\vscode_c> ./test2
  2. Main thread is working!
  3. Another thread is working!
  4. 请按任意键继续. . .
  5. terminate called without an active exception

出现这个情况的原因就是std::thread在main()结束的时候,被销毁了,解决办法就是对每一个进程都进行join或者detach(根据具体需求)。在代码上加入task.join(); 即可

join多线程

join函数会阻塞主流程,所以子线程都执行完成之后才继续执行主线程

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. void thread1()
  6. {
  7. for (int i = 0; i < 5; i++)
  8. {
  9. cout << "Thread 1 is working!\n" ;
  10. Sleep(200);
  11. }
  12. }
  13. void thread2()
  14. {
  15. for (int i = 0; i < 5; i++)
  16. {
  17. cout << "Thread 2 is working!\n" ;
  18. Sleep(100);
  19. }
  20. }
  21. int main()
  22. {
  23. thread task01(thread1);
  24. thread task02(thread2);
  25. task01.join();
  26. task02.join();
  27. for (int i = 0; i < 5; i++)
  28. {
  29. cout << "Main thread is working!" << endl;
  30. Sleep(200);
  31. }
  32. system("pause");
  33. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. Thread 1 is working!
  3. Thread 2 is working!
  4. Thread 2 is working!
  5. Thread 1 is working!
  6. Thread 2 is working!
  7. Thread 2 is working!
  8. Thread 1 is working!
  9. Thread 2 is working!
  10. Thread 1 is working!
  11. Thread 1 is working!
  12. Main thread is working!
  13. Main thread is working!
  14. Main thread is working!
  15. Main thread is working!
  16. Main thread is working!
detach多线程

可以使用detach将子线程从主流程中分离,独立运行,不会阻塞主线程:这样对于系统而言,主线程和产生的子线程实际上并没有直接依赖关系了。

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. void thread1()
  6. {
  7. for (int i = 0; i < 5; i++)
  8. {
  9. cout << "Thread 1 is working!\n" ;
  10. Sleep(200);
  11. }
  12. }
  13. void thread2()
  14. {
  15. for (int i = 0; i < 5; i++)
  16. {
  17. cout << "Thread 2 is working!\n" ;
  18. Sleep(100);
  19. }
  20. }
  21. int main()
  22. {
  23. thread task01(thread1);
  24. thread task02(thread2);
  25. task01.detach();
  26. task02.detach();
  27. for (int i = 0; i < 5; i++)
  28. {
  29. cout << "Main thread is working!\n";
  30. Sleep(200);
  31. }
  32. system("pause");
  33. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. Main thread is working!
  3. Thread 1 is working!
  4. Thread 2 is working!
  5. Thread 2 is working!
  6. Main thread is working!
  7. Thread 1 is working!
  8. Thread 2 is working!
  9. Thread 2 is working!
  10. Main thread is working!
  11. Thread 1 is working!
  12. Thread 2 is working!
  13. Main thread is working!
  14. Thread 1 is working!
  15. Main thread is working!
  16. Thread 1 is working!

可以看到detach情况下两个子线程还没有结束主线程也在继续运行,即 thread1 和 thread2 以及main thread三者是完全并行的。

带参数多线程

因为thread创建时用了函数的地址,那么对于有参数的函数如何传递参数呢?答案很简单,在创建时后面的参数列表放置参数即可

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. //定义带参数子线程
  6. void thread_test(int num)
  7. {
  8. for (int i = 0; i < num; i++)
  9. {
  10. cout << "Test thread is working!\n";
  11. Sleep(100);
  12. }
  13. }
  14. int main()
  15. {
  16. thread task(thread_test, 5); //带参数子线程
  17. task.detach();
  18. for (int i = 0; i < 5; i++)
  19. {
  20. cout << "Main thread is working!\n";
  21. Sleep(200);
  22. }
  23. system("pause");
  24. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. Main thread is working!
  3. Test thread is working!
  4. Test thread is working!
  5. Main thread is working!
  6. Test thread is working!
  7. Test thread is working!
  8. Main thread is working!
  9. Test thread is working!
  10. Main thread is working!
  11. Main thread is working!
多线程同步

当多个线程对一个共享变量进行操作时,就需要格外注意了! 因为由于线程执行顺序的不确定性,可能会导致产生不符合预期的结果。因此需要对一些变量进行保护和限制才行。

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. int totalNum = 20;
  6. void thread1()
  7. {
  8. while (totalNum > 0)
  9. {
  10. cout << totalNum <<endl;
  11. totalNum--;
  12. Sleep(100);
  13. }
  14. }
  15. void thread2()
  16. {
  17. while (totalNum > 0)
  18. {
  19. cout << totalNum <<endl;
  20. totalNum--;
  21. Sleep(100);
  22. }
  23. }
  24. void thread3()
  25. {
  26. while (totalNum > 0)
  27. {
  28. cout << totalNum << endl;
  29. totalNum--;
  30. Sleep(100);
  31. }
  32. }
  33. int main()
  34. {
  35. thread task1(thread1);
  36. thread task2(thread2);
  37. thread task3(thread3);
  38. task1.join();
  39. task2.join();
  40. task3.join();
  41. }

三个线程做的事情一模一样,就是如果这个全局变量大于0,那么输出一下并且执行减1操作。那么预期的结果希望是每一行一个数字并且换行,并且每个数字是依次递减的,但是这个程序实际上结果是不确定的,下面是某一次的运行结果:

  1. PS D:\vscode_c> ./test
  2. 2020
  3. 20
  4. 17
  5. 16
  6. 16
  7. 14
  8. 14
  9. 13
  10. 11
  11. 10
  12. 9
  13. 8
  14. 8
  15. 6
  16. 5
  17. 5
  18. 3
  19. 2
  20. 2

可以看到,有些数字重复输出了,并且有些数字没有输出,并且甚至换行也可能出现不按照预期的结果。主要原因是由于第一个线程对变量操作的过程中,第二个线程也对同一个变量进行各操作,导致第一个线程处理完后的输出有可能是线程二操作的结果。

因此,为了对数据进行正确的访问,需要引入mutex互斥机制,从而支持线程互斥访问。

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. #include<mutex>
  5. using namespace std;
  6. int totalNum = 20;
  7. mutex mut; //互斥对象
  8. void thread1()
  9. {
  10. while (totalNum > 0)
  11. {
  12. mut.lock(); //加锁
  13. if (totalNum < 0)
  14. break;
  15. cout << totalNum <<endl;
  16. totalNum--;
  17. Sleep(100);
  18. mut.unlock(); //解锁
  19. }
  20. }
  21. void thread2()
  22. {
  23. while (totalNum > 0)
  24. {
  25. mut.lock(); //加锁
  26. if (totalNum < 0)
  27. break;
  28. cout << totalNum << endl;
  29. totalNum--;
  30. Sleep(100);
  31. mut.unlock(); //解锁
  32. }
  33. }
  34. void thread3()
  35. {
  36. while (totalNum > 0)
  37. {
  38. mut.lock(); //加锁
  39. if (totalNum < 0)
  40. break;
  41. cout << totalNum << endl;
  42. totalNum--;
  43. Sleep(100);
  44. mut.unlock(); //解锁
  45. }
  46. }
  47. int main()
  48. {
  49. thread task1(thread1);
  50. thread task2(thread2);
  51. thread task3(thread3);
  52. task1.join();
  53. task2.join();
  54. task3.join();
  55. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. 20
  3. 19
  4. 18
  5. 17
  6. 16
  7. 15
  8. 14
  9. 13
  10. 12
  11. 11
  12. 10
  13. 9
  14. 8
  15. 7
  16. 6
  17. 5
  18. 4
  19. 3
  20. 2
  21. 1
  22. 0

深入探索thread类

std::thread类的构造函数是使用可变参数模板实现的,即可以传递任意个参数,第一个参数是线程的入口函数,而后面的若干个参数是该函数的参数。第一个参数可以是函数指针、仿函数、lambda表达式或者std::function;

thread类的构造函数
  • 默认构造函数,创建一个空的 std::thread 执行对象

    1. thread() noexcept;
  • 初始化构造函数,创建一个 std::thread 对象,该 std::thread 对象可被 joinable,新产生的线程会调用 fn 函数,该函数的参数由 args 给出

    1. template <class Fn, class... Args>
    2. explicit thread(Fn&& fn, Args&&... args);
  • 拷贝构造函数(被禁用),意味着 std::thread 对象不可拷贝构造

    1. thread(const thread&) = delete;
  • 移动语义构造函数,调用成功之后 x 不代表任何 std::thread 执行对象

    1. thread(thread&& x) noexcept;

Tips:可被 joinablestd::thread 对象必须在他们销毁之前被主线程 join 或者将其设置为 detached.

线程对象只能移动,不能复制。

构造函数的代码例子:

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. void thread1()
  6. {
  7. cout << "this is thread1!\n";
  8. }
  9. void thread2(int n)
  10. {
  11. cout << "this is thread2! n is "<<n<<"\n";
  12. }
  13. int main()
  14. {
  15. thread task0; //task0并不是一个线程
  16. thread task1(thread1); //无参数构造函数
  17. thread task2(thread2, 1); //含参数的构造函数
  18. //thread task3(thread2); //拷贝构造函数是被禁止的
  19. thread task4(std::move(task2)); //move语义
  20. task1.join();
  21. task4.join();
  22. }
thread类的一些常用函数

get_id: 获取线程 ID,返回一个类型为 std::thread::id 的对象

joinable: 检查线程是否可被 join。检查当前的线程对象是否表示了一个活动的执行线程,由默认构造函数创建的线程是不能被 join 的。另外,如果某个线程 已经执行完任务,但是没有被 join 的话,该线程依然会被认为是一个活动的执行线程,因此也是可以被 join 的

detach: Detach 线程。 将当前线程对象所代表的执行实例与该线程对象分离,使得线程的执行可以单独进行。一旦线程执行完毕,它所分配的资源将会被释放

swap: Swap 线程,交换两个线程对象所代表的底层句柄(underlying handles)

native_handle: 返回 native handle

std::this_thread 命名空间中相关辅助函数

get_id: 获取线程 ID

yield: 当前线程放弃执行,操作系统调度另一线程继续执行

sleep_until: 线程休眠至某个指定的时刻(time point),该线程才被重新唤醒

sleep_for: 线程休眠某个指定的时间片(time span),该线程才被重新唤醒,不过由于线程调度等原因,实际休眠时间可能比 sleep_duration 所表示的时间片更长

竞争条件

数据竞争

并发代码中最常见的错误之一就是竞争条件(race condition),因此在部分变量的访问时需要设置限制条件使其互斥。

例如之前的程序中,会看到多线程的std::cout就会有非预期输出,主要是因为cout就是一个典型的共享变量,多个线程执行cout会共享缓冲区,导致一个线程刚放入一些内容到缓冲区就被另一个进程输出了。

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. using namespace std;
  5. void thread1()
  6. {
  7. cout << "this is thread1!"<<endl;
  8. }
  9. void thread2(int n)
  10. {
  11. cout << "this is thread2! n is "<<n<<endl;
  12. }
  13. int main()
  14. {
  15. thread task0; //task0并不是一个线程
  16. thread task1(thread1); //无参数构造函数
  17. thread task2(thread2, 1); //含参数的构造函数
  18. task1.join();
  19. task2.join();
  20. cout << "main thread!" << endl;
  21. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. this is thread1!this is thread2! n is
  3. 1
  4. main thread!

可以看到输出并不符合我们的预期,主要原因就是这些线程的cout是共享缓冲区的,执行顺序是不可预期的。

互斥单元

解决办法就是要对cout这个共享资源进行保护。在c++中,可以使用互斥锁std::mutex进行资源保护,头文件是#include <mutex>,共有两种操作:锁定(lock)与解锁(unlock)。将cout重新封装成一个线程安全的函数。从而使得多线程对cout这个共享资源访问互斥。

  1. #include <iostream>
  2. #include <thread>
  3. #include <Windows.h>
  4. #include<mutex>
  5. using namespace std;
  6. std::mutex mu; //通过mutex变量对共享部分加锁从而实现互斥访问
  7. void thread1()
  8. {
  9. mu.lock();
  10. cout << "this is thread1!"<<endl;
  11. mu.unlock();
  12. }
  13. void thread2(int n)
  14. {
  15. mu.lock();
  16. cout << "this is thread2! n is "<<n<<endl;
  17. mu.unlock();
  18. }
  19. int main()
  20. {
  21. thread task0; //task0并不是一个线程
  22. thread task1(thread1); //无参数构造函数
  23. thread task2(thread2, 1); //含参数的构造函数
  24. task1.detach();
  25. task2.detach();
  26. mu.lock();
  27. cout << "main thread!" << endl;
  28. mu.unlock();
  29. }

运行结果:

  1. PS D:\vscode_c> ./test
  2. this is thread1!
  3. this is thread2! n is 1
  4. main thread!
死锁

如果将某个mutex上锁了,却一直不释放,另一个线程访问该锁保护的资源的时候,就会发生死锁,这种情况下使用lock_guard可以保证析构的时候能够释放锁。c++库已经提供了std::lock_guard类模板,可以保证在类的构造函数中创建资源,在析构函数中释放资源,因为就算发生了异常,c++也能保证类的析构函数能够执行。

但是如果有多个mutex变量,仅仅使用lock_guard并不能保证不会发生死锁。

  1. Thread A Thread B
  2. _mu.lock() _mu2.lock()
  3. //死锁 //死锁
  4. _mu2.lock() _mu.lock()

这种情况下通过一些逻辑严格限制两个锁的顺序也是可以避免死锁的。

c++标准库中提供了std::lock()函数,能够保证将多个互斥锁同时上锁,

  1. std::lock(_mu, _mu2);

1114. 按序打印

我们提供了一个类:

  1. public class Foo {
  2. public void first() { print("first"); }
  3. public void second() { print("second"); }
  4. public void third() { print("third"); }
  5. }

三个不同的线程将会共用一个 Foo 实例。

  • 线程 A 将会调用 first() 方法
  • 线程 B 将会调用 second() 方法
  • 线程 C 将会调用 third() 方法

请设计修改程序,以确保 second() 方法在 first() 方法之后被执行,third() 方法在 second() 方法之后被执行。

示例 1:

  1. 输入: [1,2,3]
  2. 输出: "firstsecondthird"
  3. 解释:
  4. 有三个线程会被异步启动。
  5. 输入 [1,2,3] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 second() 方法,线程 C 将会调用 third() 方法。
  6. 正确的输出是 "firstsecondthird"

分析:由于需要two()one()之前执行,所以two()必须等待one()执行后的某个条件达成,使用锁来实现同步。因此通过两个mutex变量,起初将两个变量锁上,只有第一个完成后将打开一把锁,然后第二个将运行,然后第二个运行后将打开另一把锁,第三个运行,需要两把锁都解开才可以运行。

  1. class Foo {
  2. public:
  3. mutex smx;
  4. mutex tmx;
  5. Foo() {
  6. smx.lock();
  7. tmx.lock();
  8. }
  9. void first(function<void()> printFirst) {
  10. // printFirst() outputs "first". Do not change or remove this line.
  11. printFirst();
  12. smx.unlock();
  13. }
  14. void second(function<void()> printSecond) {
  15. lock_guard<mutex> lg(smx);
  16. // printSecond() outputs "second". Do not change or remove this line.
  17. printSecond();
  18. tmx.unlock();
  19. }
  20. void third(function<void()> printThird) {
  21. lock_guard<mutex> lg(tmx);
  22. // printThird() outputs "third". Do not change or remove this line.
  23. printThird();
  24. }
  25. };

发表评论

表情:
评论列表 (有 0 条评论,259人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Scala

    第一节:概述 为什么学习Scala ? Apache Spark 是专为大规模数据快速实时处理的计算引擎/内存级大数据计算框架。Apache Spark 是由Sca