【多线程】C/C++多线程的几种实现方式以及线程同步的实现-Mutex、std::lock_guard

【多线程】C/C++多线程的几种实现方式以及线程同步之互斥锁的实现-Mutex、std::lock_guard

  • 前言
  • 一、线程概述
  • 二、线程创建
    • 2.1 Linux pthread_creat
      • 2.1.1 函数及定义
      • 2.1.2 实例
    • 2.2 C++ thread类
      • 2.2.1 thread类定义
      • 2.2.2 get_id()
      • 2.2.3 实例
  • 三、线程同步
    • 3.1 什么是线程同步
      • 3.1.1 线程同步原因
      • 3.1.2 线程同步方式
    • 3.2 实例
      • 3.2.1 互斥锁
      • 3.2.2 读写锁
      • 3.2.3 条件变量
      • 3.2.4 信号量

前言

  相信很多同学在做项目的时候都需要用到多线程的知识,我这里进行简单的总结,希望能给各位一些参考。
  这里参考了优秀文章/资源并进行整理,放在前面,大家也可以去看看。

  1. 苏丙榅-多线程

一、线程概述

  线程是轻量级的进程(LWP:light weight process),在 Linux 环境下线程的本质仍是进程。在计算机上运行的程序是一组指令及指令参数的组合,指令按照既定的逻辑控制计算机运行。操作系统会以进程为单位,分配系统资源,可以这样理解,进程是资源分配的最小单位,线程是操作系统调度执行的最小单位。
先从概念上了解一下线程和进程之间的区别:

  1. 进程有自己独立的地址空间,多个线程共用同一个地址空间
  • 线程更加节省系统资源,效率不仅可以保持的,而且能够更高
  • 在一个地址空间中多个线程独享:每个线程都有属于自己的栈区,寄存器 (内核中管理的)
  • 在一个地址空间中多个线程共享:代码段,堆区,全局数据区,打开的文件 (文件描述符表) 都是线程共享的
  1. 线程是程序的最小执行单位,进程是操作系统中最小的资源分配单位
  • 每个进程对应一个虚拟地址空间,一个进程只能抢一个 CPU 时间片
  • 一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的 CPU 时间片
  1. CPU 的调度和切换:线程的上下文切换比进程要快的多
  • 上下文切换:进程 / 线程分时复用 CPU 时间片,在切换之前会将上一个任务的状态进行保存,下次切换回这个任务的时候,加载这个状态继续运行,任务从保存到再次加载这个过程就是一次上下文切换。
  1. 线程更加廉价,启动速度更快,退出也快,对系统资源的冲击小。

二、线程创建

  本人开发主要在Linux系统中,最常用的便是pthread_creat方式。

2.1 Linux pthread_creat

2.1.1 函数及定义

  1. 线程IDpthread_t
      每一个线程都有一个唯一的线程 ID,ID 类型为 pthread_t,是一个无符号长整形数,定义如下:
      
/* Thread identifiers.  The structure of the attribute type is not
   exposed on purpose.  */
typedef unsigned long int pthread_t;
  1. 线程创建函数pthread_create
//#include 

/* Create a new thread, starting with execution of START-ROUTINE
   getting passed ARG.  Creation attributed come from ATTR.  The new
   handle is stored in *NEWTHREAD.  */
extern int pthread_create (pthread_t *__restrict __newthread,
			   const pthread_attr_t *__restrict __attr,
			   void *(*__start_routine) (void *),
			   void *__restrict __arg) __THROWNL __nonnull ((1, 3));

参数:

  • __newthread: 传出参数,是无符号长整形数,线程创建成功,会将线程 ID 写入到这个指针指向的内存中
  • __attr: 线程的属性,一般情况下使用默认属性即可,一般写 NULL
  • __start_routine: 函数指针,创建出的子线程的处理动作,也就是该函数在子线程中执行。
  • __arg: 作为实参传递到 __start_routine指针指向的函数内部
    返回值:线程创建成功返回 0,创建失败返回对应的错误号

2.1.2 实例

#include 
#include 
#include 
#include 
#include 
// 子线程函数
void* func(void* arg)
{
    printf("子线程ID: %ld \n", pthread_self());
    printf("hello world!\n");
    return NULL;
}
int main()
{
    // 创建一个子线程
    pthread_t id;
    pthread_create(&id, NULL, func, NULL);
    printf("子线程创建成功, 线程ID: %ld\n", id);
    // 打印主线程
    printf("主线程ID: %ld\n", pthread_self());
    //等待子线程结束
    pthread_join(id, NULL);
    return 0;
}

执行结果

子线程创建成功, 线程ID: 139989099230976
主线程ID: 139989107406592
子线程ID: 139989099230976 
hello world!

2.2 C++ thread类

  C++11 之前,C++ 语言没有对并发编程提供语言级别的支持,这使得我们在编写可移植的并发程序时,存在诸多的不便。现在 C++11 中增加了线程以及线程相关的类,很方便地支持了并发编程,使得编写的多线程程序的可移植性得到了很大的提高。
  C++11 中提供的线程类叫做 std::thread,基于这个类创建一个新的线程非常的简单,只需要提供线程函数或者函数对象即可,并且可以同时指定线程函数的参数。我们首先来了解一下这个类提供的一些常用 API

2.2.1 thread类定义

// ①
thread() noexcept;
// ②
thread( thread&& other ) noexcept;
// ③
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
// ④
thread( const thread& ) = delete;
  1. 构造函数①:默认构造函,构造一个线程对象,在这个线程中不执行任何处理动作
  2. 构造函数②:移动构造函数,将 other 的线程所有权转移给新的 thread 对象。之后 other 不再表示执行线程。
  3. 构造函数③:创建线程对象,并在该线程中执行函数 f 中的业务逻辑,args 是要传递给函数 f 的参数
  • 任务函数 f 的可选类型有很多,具体如下:

  • 普通函数,类成员函数,匿名函数,仿函数(这些都是可调用对象类型)

  • 可以是可调用对象包装器类型,也可以是使用绑定器绑定之后得到的类型(仿函数)

  1. 构造函数④:使用 =delete 显示删除拷贝构造,不允许线程对象之间的拷贝

2.2.2 get_id()

  与pthread_self()类似,get_id()是获取线程 ID 的函数

inline thread::id get_id() noexcept

2.2.3 实例

#include 
#include 

using namespace std;

void func(int num, string str)
{
    for (int i = 0; i < 3; ++i)
    {
        cout << "子线程1: i = " << i << "num: " << num << ", str: " << str << endl;
    }
}

void func1()
{
    for (int i = 0; i < 3; ++i)
    {
        cout << "子线程2: i = " << i << endl;
    }
}

int main()
{
    cout << "主线程的线程ID: " << this_thread::get_id() << endl;
    thread t(func, 520, "i love you");
    thread t1(func1);
    cout << "线程t 的线程ID: " << t.get_id() << endl;
    cout << "线程t1的线程ID: " << t1.get_id() << endl;
    //等待子线程结束
    t.join();
    t1.join();
}

三、线程同步

3.1 什么是线程同步

  假设有 4 个线程 A、B、C、D,当前一个线程 A 对内存中的共享资源进行访问的时候,其他线程 B, C, D 都不可以对这块内存进行操作,直到线程 A 对这块内存访问完毕为止,B,C,D 中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。

3.1.1 线程同步原因

  在研究线程同步之前,先来看一个两个线程交替数数(每个线程数 5 个数,交替数到 10)的例子:

#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MAX 10
// 全局变量
int number;

// 线程处理函数
void* funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }
    return NULL;
}

void* funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        int cur = number;
        cur++;
        number = cur;
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        usleep(5);
    }
    return NULL;
}
int main(int argc, const char* argv[])
{
    pthread_t p1, p2;
    // 创建两个子线程
    pthread_create(&p1, NULL, funcA_num, NULL);
    pthread_create(&p2, NULL, funcB_num, NULL);
    // 阻塞,资源回收
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    return 0;
}

执行结果

Thread B, id = 140685318096640, number = 1
Thread A, id = 140685326489344, number = 1
Thread B, id = 140685318096640, number = 2
Thread A, id = 140685326489344, number = 2
Thread B, id = 140685318096640, number = 3
Thread A, id = 140685326489344, number = 3
Thread A, id = 140685326489344, number = 4
Thread B, id = 140685318096640, number = 5
Thread A, id = 140685326489344, number = 5
Thread B, id = 140685318096640, number = 6

  通过对上面例子的测试,可以看出虽然每个线程内部循环了 5次每次数一个数,但是最终没有数到 10,有些数字被重复数了多次,其原因就是没有对线程进行同步处理,造成了数据的混乱。具体导致这种现象的原因涉及到寄存器、一级缓存、二级缓存、三级缓存相关知识,这里不进行展开介绍。

3.1.2 线程同步方式

  对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。

3.2 实例

  首先进行互斥锁的使用介绍,分别介绍c语言的pthread_mutex_lock方式以及C++的mutex

3.2.1 互斥锁

  互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的线程只能顺序执行 (不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。

  1. pthread_mutex_lock
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MAX 5
// 全局变量
int number;

// 创建一把互斥锁
// 全局变量, 多个线程共享
pthread_mutex_t mutex;

// 线程处理函数
void* funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // 如果线程A加锁成功, 不阻塞
        // 如果B加锁成功, 线程A阻塞
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        pthread_mutex_unlock(&mutex);
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }

    return NULL;
}

void* funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // a加锁成功, b线程访问这把锁的时候是锁定的
        // 线程B先阻塞, a线程解锁之后阻塞解除
        // 线程B加锁成功了
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        number = cur;
        pthread_mutex_unlock(&mutex);
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        usleep(5);
    }

    return NULL;
}

int main(int argc, const char* argv[])
{
    pthread_t p1, p2;

    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);

    // 创建两个子线程
    pthread_create(&p1, NULL, funcA_num, NULL);
    pthread_create(&p2, NULL, funcB_num, NULL);

    // 阻塞,资源回收
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    // 销毁互斥锁
    // 线程销毁之后, 再去释放互斥锁
    pthread_mutex_destroy(&mutex);

    return 0;
}

执行结果:

Thread A, id = 140496895964928, number = 1
Thread B, id = 140496887572224, number = 2
Thread A, id = 140496895964928, number = 3
Thread A, id = 140496895964928, number = 4
Thread A, id = 140496895964928, number = 5
Thread A, id = 140496895964928, number = 6
Thread B, id = 140496887572224, number = 7
Thread B, id = 140496887572224, number = 8
Thread B, id = 140496887572224, number = 9
Thread B, id = 140496887572224, number = 10
  1. C++ mutex
    使用C++的 mutex类是真的方便,这里我将上面的程序进行了改动,使用mutex类进行上锁
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define MAX 5

using namespace std;

// 全局变量
int number;
mutex g_num_mutex;
// 创建一把互斥锁
// 全局变量, 多个线程共享


// 线程处理函数
void funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // 如果线程A加锁成功, 不阻塞
        // 如果B加锁成功, 线程A阻塞
        g_num_mutex.lock();
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        g_num_mutex.unlock();
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }
}

void funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // a加锁成功, b线程访问这把锁的时候是锁定的
        // 线程B先阻塞, a线程解锁之后阻塞解除
        // 线程B加锁成功了
        g_num_mutex.lock();
        int cur = number;
        cur++;
        number = cur;
        g_num_mutex.unlock();
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        usleep(5);
    }
}

int main(int argc, const char* argv[])
{

    // 创建两个子线程
    thread t1(funcA_num, (void *)NULL);
    thread t2(funcB_num, (void *)NULL);

    // 阻塞,资源回收
    t1.join();
    t2.join();

    return 0;
}

执行结果:

Thread A, id = 140232361907968, number = 1
Thread A, id = 140232361907968, number = 2
Thread A, id = 140232361907968, number = 3
Thread A, id = 140232361907968, number = 4
Thread A, id = 140232361907968, number = 5
Thread B, id = 140232353515264, number = 6
Thread B, id = 140232353515264, number = 7
Thread B, id = 140232353515264, number = 8
Thread B, id = 140232353515264, number = 9
Thread B, id = 140232353515264, number = 10
  1. std::lock_guard
      lock_guard 是 C++11 新增的一个模板类,使用这个类,可以简化互斥锁 lock()unlock() 的写法,同时也更安全。这个模板类的定义和常用的构造函数原型如下:
// 类的定义,定义于头文件 
template< class Mutex >
class lock_guard;
// 常用构造函数
explicit lock_guard( mutex_type& m );

lock_guard 在使用上面提供的这个构造函数构造对象时,会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁,从而保证了互斥量的正确操作,避免忘记 unlock() 操作而导致线程死锁。lock_guard 使用了 RAII 技术,就是在类构造函数中分配资源,在析构函数中释放资源,保证资源出了作用域就释放。

使用 lock_guard 对上面的例子进行修改,代码如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define MAX 5

using namespace std;

// 全局变量
int number;
mutex g_num_mutex;
// 创建一把互斥锁
// 全局变量, 多个线程共享


// 线程处理函数
void funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // 如果线程A加锁成功, 不阻塞
        // 如果B加锁成功, 线程A阻塞
        lock_guard<mutex> lock(g_num_mutex);
        int cur = number;
        cur++;
        // usleep(10);
        number = cur;
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }
}

void funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // a加锁成功, b线程访问这把锁的时候是锁定的
        // 线程B先阻塞, a线程解锁之后阻塞解除
        // 线程B加锁成功了
        lock_guard<mutex> lock(g_num_mutex);
        int cur = number;
        cur++;
        number = cur;
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        // usleep(5);
    }
}

int main(int argc, const char* argv[])
{

    // 创建两个子线程
    thread t1(funcA_num, (void *)NULL);
    thread t2(funcB_num, (void *)NULL);

    // 阻塞,资源回收
    t1.join();
    t2.join();

    return 0;
}


执行结果:

Thread A, id = 140307365684992, number = 1
Thread A, id = 140307365684992, number = 2
Thread A, id = 140307365684992, number = 3
Thread A, id = 140307365684992, number = 4
Thread B, id = 140307357292288, number = 5
Thread B, id = 140307357292288, number = 6
Thread B, id = 140307357292288, number = 7
Thread B, id = 140307357292288, number = 8
Thread B, id = 140307357292288, number = 9
Thread A, id = 140307365684992, number = 10

  通过修改发现代码被精简了,而且不用担心因为忘记解锁而造成程序的死锁,但是这种方式也有弊端,在上面的示例程序中整个for循环的体都被当做了临界区,多个线程是线性的执行临界区代码的,因此临界区越大程序效率越低,还是需要根据实际情况选择最优的解决方案。

待更新内容

3.2.2 读写锁

3.2.3 条件变量

3.2.4 信号量

你可能感兴趣的:(C/C++,多线程,c语言,c++,linux)