Bootstrap

Linux线程(二)—— 多线程(同步与互斥、线程安全)

前置概念

大家都能看到的资源:公共资源

  1. 互斥:任何一个时刻,都只允许一个执行流在进行共享资源的访问 (可通过加锁解决)。本质就是将临界资源独立使用。
  2. 原子性:要么不做,要么做完,只有两种确定状态的属性。

多线程中,如果有一个全局的变量,这个变量是被所有执行流共享的。

线程大部分资源都会直接或间接共享,而只要存在共享,就可能存在并发访问的问题。

任何一个时刻,都只允许一个执行流再进行流在进行访问的共享资源,叫做临界资源

所以要对临界资源进行一定的保护。

临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做临界区

如果想让多个线程安全的访问临界资源,就可以采用加锁,也就是互斥访问。

互斥

任何一个时刻,都只允许一个执行流再进行共享资源的访问 (可通过加锁解决)。本质就是将临界资源独立使用。

下面一个小实验没有对临界资源做保护,存在并发访问的问题:

//模拟抢票

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int tickets = 10000;    //临界资源

void* ThreadRoutine(void* args)
{
    string name = static_cast<const char*>(args);

    while(true)
    {
        if(tickets > 0)     //临界区
        {
            usleep(2000);   //模拟抢票所花费时间
            cout << name << " get ticket:  " << tickets-- << endl;    //临界区,因为对数据进行++/--操作并不是原子的,所以可能存在并发访问问题。

        }
        else
        {
            break;
        }
        usleep(1000);
    }
    
    
    return nullptr;
}

int main()
{
    //创建4个进程
    pthread_t tids[4];
    int size = sizeof(tids) / sizeof(tids[0]);
    for(int i = 0; i < size; i++)
    {
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        pthread_create(tids+i, nullptr, ThreadRoutine, name);

    }

    for(int i = 0; i < size; i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

可以看见没有对临界区做保护,票被买成了负数。

为什么可能无法获得正确结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是一个原子操作

-- 操作并不是原子操作,而是对应三条汇编指令:

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

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

互斥量

初始化

动态分配

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
											const pthread_mutexattr_t *restrict attr);
//参数:
//mutex:要初始化的条件变量
//attr:NULL

静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//**静态**或**全局**的锁可以用这个方式初始化,之后也不需要手动销毁
//局部锁不可以使用!

销毁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
//加锁失败会将当前执行流阻塞

调用 pthread_mutex_lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
//非阻塞版本

解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

当上面的抢票小实验引入互斥锁之后,发现票数正常了,并且速度降低了很多,这是因为并发访问的问题不存在了。

//模拟抢票

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int tickets = 10000;    //临界资源,需要枷锁保证安全
pthread_mutex_t mutex;  //定义一个互斥锁

void* ThreadRoutine(void* args)
{
    string name = static_cast<const char*>(args);

    while(true)
    {
        pthread_mutex_lock(&mutex);     //进入临界区前先加锁,所有线程都要遵守这个规则
        if(tickets > 0)     //临界区
        {
            usleep(2000);   //模拟抢票所花费时间
            cout << name << " get ticket:  " << tickets-- << endl;    //临界区

            pthread_mutex_unlock(&mutex);   //退出临界区解锁
        }
        else
        {
            pthread_mutex_unlock(&mutex);   //退出临界区解锁
            break;
        }

        usleep(1000);   //充当抢票后做的后续动作
    }
    
    
    return nullptr;
}

int main()
{
    pthread_mutex_init(&mutex, nullptr);    //初始化互斥锁

    //创建4个进程
    pthread_t tids[4];
    int size = sizeof(tids) / sizeof(tids[0]);
    for(int i = 0; i < size; i++)
    {
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        pthread_create(tids+i, nullptr, ThreadRoutine, name);

    }

    for(int i = 0; i < size; i++)
    {
        pthread_join(tids[i], nullptr);
    }

    pthread_mutex_destroy(&mutex);     //销毁互斥锁
    return 0;
}

Untitled

细节知识

  • 访问同一个临界资源的线程,都必须使用同一把锁进行加锁保护,所有线程必须遵守,没有例外。

  • 每一个线程访问临界区之前都必须加锁,所以加锁的本质是给临界区加锁。加锁粒度尽量细一些,临界区越小越好,不然代码效率会降低。

  • 所有线程访问临界区时,需要先加锁,也就是说所有线程都必须要先看到同一把锁。所以锁本身就是公共资源,如何保证锁自己的安全?加锁和解锁本身就是原子的!所以是安全的。

  • 临界区可以是一条代码,也可以是一批代码。

    • 线程在加锁后运行临界区代码时会被切换吗?

      线程加锁后运行临界区代码时也有可能被切换,不要特殊化加锁解锁和临界区代码。

    • 切换后会有影响吗,会有访问安全的问题吗?

      不会,因为在申请了锁的线程不在的期间,任何其他线程都无法进入临界区,因为无法成功申请到锁

  • 这也正是体现互斥带来的串行化的表现,站在其他线程的角度,对其他线程有意义的状态就是:锁被当前线程申请(持有锁)、锁被当前线程释放(不持有锁),原子性就体现在这里。

互斥锁实现原理

为了实现互斥锁 操作,大多数体系结构都提供了swapexchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性

以下是pthread_mutex_lock的汇编伪代码

lock:
	movb $0, %a1     # 把0值放进寄存器a1里
	xchgb %a1, mutex # 交换a1寄存器的内容和锁的值(无线程使用锁时,metux的值为1)—— 加锁,只有一条汇编语句,原子性
	if (%a1 > 0)
		return 0; # 得到锁,加锁成功
	else
		挂起等待;
	goto lock;

pthread_mutex_unlock的汇编伪代码

unlock:
	movb $1 mutex  #把1赋给锁	
	唤醒等待的线程;
	return 0;

封装一个互斥锁

使用自己封装的thread类运行售票程序

//Thread.h
#pragma once

#include <iostream>
#include <string>
#include <pthread.h>

class Thread
{
    typedef void (*FUNC_t)(void *);
    enum Thread_STATUS
    {
        NEW,
        RUNNING,
        EXITED
    };

public:
    Thread(int num, FUNC_t func, void* args)
        :_args(args)
        ,_status(NEW)
        ,_func(func)
    {
        char name[64];
        snprintf(name, 64, "Thread-%d", num);
        _name = name;
    }
    ~Thread()
    {}

    pthread_t threadid()
    {
        if(_status == RUNNING)
            return _tid;
        else
            return 0;
    }
    std::string threadname()
    {
        return _name;
    }
    Thread_STATUS status()
    {
        return _status;
    }

    static void* FuncHelper(void* args)
    {
        Thread* td = static_cast<Thread*>(args);
        
        //td->_func(td->_args);
        (*td) ();
    }
    void operator() ()
    {
        _func(_args);
    }

    void run()
    {
        int n = pthread_create(&_tid, nullptr, FuncHelper, this);
        if(n != 0)
        {
            exit(1);
        }
        _status = RUNNING;
    }

    void join()
    {
        int n = pthread_join(_tid, nullptr);
        if(n != 0)
        {
            std::cerr << "thread join error" << std::endl;
            return;
        }
        _status = EXITED;
    }

private:
    pthread_t _tid;
    std::string _name;
    void* _args;
    Thread_STATUS _status;
    FUNC_t _func;
};

#include "Thread.h"
#include "LockGuard.hpp"
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;

int tickets = 1000;

void ThreadRoutine(void* args)
{
    string name = static_cast<const char *>(args);

    while(true)
  
        if(tickets > 0)
        {
            usleep(2000);
            cout << name << " get a ticket : " << tickets-- << endl;
        }
        else
        {
            break;
        }

    }
}

int main()
{
    Thread t1(1, ThreadRoutine, (void*)"thread-1");
    Thread t2(2, ThreadRoutine, (void*)"thread-2");
    Thread t3(3, ThreadRoutine, (void*)"thread-3");
    Thread t4(4, ThreadRoutine, (void*)"thread-4");
   
    t1.run();
    t2.run();
    t3.run();
    t4.run();

    t1.join();
    t2.join();
    t3.join();
    t4.join();

    return 0;
}

加上自己封装的互斥锁

//LockGuard.h
#pragma once

#include <iostream>
#include <pthread.h>

class Mutex
{
public:
    Mutex(pthread_mutex_t* mutex)
        :_mutex(mutex)
    {}
    ~Mutex()
    {}

    void lock()
    {
        pthread_mutex_lock(_mutex);
    }
    void unlock()
    {
        pthread_mutex_unlock(_mutex);
    }

private:
    pthread_mutex_t* _mutex;
};

class lockGuard
{
public:
    lockGuard(pthread_mutex_t mutex)
        :mx(&mutex)
    {
        mx.lock();
    }
    ~lockGuard()
    {
        mx.unlock();    //离开区域调用析构自动解锁
    }
private:
    Mutex mx;
};
//BuyTickets.cpp
#include "Thread.h"
#include "LockGuard.hpp"
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;

int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  //定义并初始化互斥锁

void ThreadRoutine(void* args)
{
    string name = static_cast<const char *>(args);

    while(true)
    {
        lockGuard lg(&mutex);     //加锁,离开循环区域调用析构自动解锁
        if(tickets > 0)
        {
            usleep(2000);
            cout << name << " get a ticket : " << tickets-- << endl;
        }
        else
        {
            break;
        }

        usleep(1000);
    }
}

int main()
{
    Thread t1(1, ThreadRoutine, (void*)"thread-1");
    Thread t2(2, ThreadRoutine, (void*)"thread-2");
    Thread t3(3, ThreadRoutine, (void*)"thread-3");
    Thread t4(4, ThreadRoutine, (void*)"thread-4");
    cout << "name: " << t1.threadname() << ", tid: " << t1.threadid() << ", status: " << t1.status() << endl;
    cout << "name: " << t2.threadname() << ", tid: " << t2.threadid() << ", status: " << t2.status() << endl;
    cout << "name: " << t3.threadname() << ", tid: " << t3.threadid() << ", status: " << t3.status() << endl;
    cout << "name: " << t4.threadname() << ", tid: " << t4.threadid() << ", status: " << t4.status() << endl;

    t1.run();
    t2.run();
    t3.run();
    t4.run();

    t1.join();
    t2.join();
    t3.join();
    t4.join();

    return 0;
}

线程安全

线程安全和重入

线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都由函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用且不会释放的资源而处于的一种永久等待状态。

死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

如何避免死锁

核心思想:破坏死锁的4个必要条件任意一个。

  1. 不加锁
  2. 主动释放锁
  3. 按照顺序申请锁
  4. 控制线程统一释放锁

测试锁能否被其他线程解锁

#include <iostream>
#include <string>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
using namespace std;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *threadRoutine(void *args)
{
    cout << "I am a new thread " << endl;

    pthread_mutex_lock(&mutex);
    cout << "I got a mutex!" << endl;

    pthread_mutex_lock(&mutex); // 申请锁的问题,它会停下来
    cout << "I alive again" << endl;

    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, nullptr);

    sleep(3);
    cout << "main thread run begin" << endl;
    pthread_mutex_unlock(&mutex);
    cout << "main thread unlock..." << endl;

    sleep(3);
    return 0;
}

结论:其他的进程也可以解锁

线程同步

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

条件变量

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

初始化条件变量

#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
//参数:
//cond:要初始化的条件变量
//attr:NULL
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

销毁

#include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);

等待条件满足

#include <pthread.h>

int pthread_cond_wait(pthread_cond_t *restrict cond,
              pthread_mutex_t *restrict mutex);
//参数:
//cond:要在这个条件变量上等待
//mutex:互斥量,要被释放的互斥量

为什么等待条件满足需要传一个互斥锁呢?

因为条件变量是在加锁之后使用的,pthread_cond_wait在调用时,会自动释放锁。

线程在等待结束后,继续从pthread_cond_wait继续向后运行,并重新申请锁。

唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);   //唤醒等待队列中的全部进程
int pthread_cond_signal(pthread_cond_t *cond);      //唤醒等待队列中的一个进程

简单案例

#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

const int num = 4;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;       //条件变量

void* active(void* args)
{
    const char* name = static_cast<const char *> (args);

    while(true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);     //等待条件满足
        cout << name << " 活动中" << endl;
        pthread_mutex_unlock(&mutex);
    }

    return nullptr;
}

int main()
{
    pthread_t tids[num];
    for(int i = 0; i < num; i++)
    {
        char* name = new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        pthread_create(tids + 1, nullptr, active, name);
    }

    sleep(3);

    while(true)
    {
        cout << "主线程唤醒线程...." << endl;
        pthread_cond_signal(&cond);    //唤醒线程
        sleep(1);
    }
    

    for(int i = 0; i < num; i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

可以发现线程一个一个按顺序被唤醒运行

如果将

while(true)
    {
        cout << "主线程唤醒线程...." << endl;
        pthread_cond_signal(&cond);    //唤醒线程
        sleep(1);
    }

改成

while(true)
    {
        cout << "主线程唤醒线程...." << endl;
        pthread_cond_signal(&cond);    //唤醒线程
        sleep(1);
    }

主线程一次唤醒多个线程,并按顺序运行。

;