多线程 --- [ 线程池、懒汉引发的线程安全问题、其他常见的锁 ]

目录

1. 线程池

模块一:线程的封装

模块二:线程池的封装

模块三:互斥量的封装 (RAII风格)

模块四:任务的封装 

模块五:日志的封装

模块六:时间的封装

模块六:主函数

模块七: Makefile

2. 单例模式

 3. STL, 智能指针和线程安全

3.1. STL是否是线程安全的?

 3.2. 智能指针是否是线程安全的?

4. 其它常见的锁

4.1. 自旋 && 自旋锁 --- spin lock

4.2. 自旋锁的接口介绍:

初始化自旋锁:

销毁自旋锁,释放自旋锁资源:

加锁操作:

解锁操作,释放自动锁:

4.3. 自旋锁的使用

4.2. 读写锁

4.2.1. 读写锁的接口:


1. 线程池

线程池(Thread Pool)是一种用于管理和复用线程的机制,通常用于提高多线程任务处理的效率和性能。在应用程序中创建和销毁线程是耗费资源的操作,而线程池可以在应用程序启动时一次性创建一定数量的线程,并将任务分配给这些线程执行,从而减少线程创建和销毁的开销。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

线程池的主要组成部分包括:

  1. 线程池管理器(Thread Pool Manager):负责创建、销毁和管理线程池中的线程,以及分配任务给空闲线程执行。

  2. 工作队列(Work Queue):用于存储待执行的任务,当有线程变为空闲时,从工作队列中取出任务分配给该线程执行。

  3. 线程池中的线程(Worker Threads):预先创建的一定数量的线程,这些线程会从工作队列中取出任务执行,当任务执行完成后会继续保持活跃状态,等待下一个任务的到来。

线程池的优势在于可以控制线程的数量,避免线程数量过多导致系统资源耗尽,同时也可以减少线程频繁创建和销毁的开销。通过线程池,可以更高效地处理大量的任务,提高系统的性能和响应速度。

线程池的应用场景:

1.、需要大量的线程来完成任务,且完成任务的时间比较短

在处理类似Web服务器的请求响应场景中,线程池技术非常适用。每个客户端请求通常只需要短时间的处理,因此可以将请求分配给线程池中的线程来处理,而不需要为每个请求都创建一个新线程。这样可以有效地利用系统资源,提高系统的并发处理能力和响应速度。

然而,在处理长时间任务的情况下,比如Telnet连接请求,由于任务耗时较长,如果使用线程池,可能会导致线程长时间占用,降低了线程池中其他线程的可用性,甚至可能导致线程池中的线程耗尽而无法响应其他请求。因此,在这种情况下,更好的选择可能是为每个长时间任务创建一个单独的线程,以避免影响其他任务的执行。

2、对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。使用线程池是一种有效的解决方案,能够提高系统的性能和响应速度,提升用户体验。

3、接受突发性的大量请求但又不希望因为创建大量线程而导致系统资源消耗过大。使用多线程技术是非常合适的。在没有线程池的情况下,当系统接收到突发性大量的客户请求时,如果为每个请求都创建一个新线程来处理,就会导致系统中线程数量急剧增加,可能会造成系统资源(如内存)的极大消耗,甚至在极端情况下导致系统崩溃。因此可以设置一个线程池来预先创建一定数量的线程,然后在接收到突发性大量请求时,将这些请求分配给线程池中的空闲线程来处理,而不需要每个请求都创建新的线程。这样可以有效地利用少量线程处理大量请求,避免线程数量过多带来的资源消耗和系统负担问题。

模块一:线程的封装

thread:

解释一些功能:

构造: 创建thread线程对象时,需要传递 num,用以我们区分线程 (线程编号,例如1、2、3,代表着线程1、线程2、线程3); 同时需要传递 func,其作为线程的回调; arg,其作为线程所需要的参数,在未来,我们会将其和线程名组合在一起构成 thread_info对象,一起传递给线程。

create: 主要负责创建线程。对 pthread_create 进行封装,此时,我们可以发现,线程回调函数所传入的参数是thread_info,其包含 arg + 线程名。

thread_info:

作用的核心就是:将线程所需要的参数 (arg) 和 相应的线程名组装在一起。

Thread.hpp 代码如下:

#ifndef __THREAD_HPP_
#define __THREAD_HPP_

#include <iostream>
#include <pthread.h>
#include <string>
#include "Log.hpp"

const int BUFFER_SIZE = 64;

typedef void*(*Tfunc_t)(void*);

namespace Xq
{

  class thread_info
  {
  public:
    thread_info(const std::string& name = std::string (), void* arg = nullptr)
      :_name(name)
       ,_arg(arg)
    {}

    void set_info(const std::string& name, void* arg)
    {
      _name = name;
      _arg = arg;
    }

    std::string& get_name()
    {
      return _name;
    }

    void*& get_arg()
    {
      return _arg;
    }

  private:
    std::string _name;
    void* _arg;
  };

  class thread
  {
  public:
    thread(size_t num, Tfunc_t func, void* arg)
      :_func(func)
       ,_arg(arg)
    {
      // 构造线程名
      char buffer[BUFFER_SIZE] = {0};
      snprintf(buffer, BUFFER_SIZE, "%s %ld", "thread", num);
      _name = buffer;

      // 设置线程所需要的信息, 线程名 + _arg
      _all_info.set_info(_name, _arg);
    }

    // 创建线程
    void create(void)
    {
      pthread_create(&_tid, nullptr, _func, static_cast<void*>(&_all_info));
      //std::cout << "创建线程: " << _name << " success" << std::endl;
      LogMessage(NORMAL, "%s: %s %s\n", "创建线程", _name.c_str(), "success");
    }

    pthread_t get_tid()
    {
      return _tid;
    }

  private:
    std::string _name;  // 线程名
    Tfunc_t _func;     // 线程的回调
    pthread_t _tid;   //线程ID

    thread_info _all_info;  // 装载的是 线程名 + _arg;

    // 线程参数, 未来我们会将其和线程名封装到一起(thread_info),整体传递给线程
    void* _arg; 
  };
}

#endif

模块二:线程池的封装

作为一个线程池,自然需要一批线程。

构造: 我们需要线程数目、构造线程对象 ( (此时只是构造了我们封装的线程对象、并没有启动线程) ),并将其导入线程表中。 初始化我们的锁以及条件变量。

析构: 释放我们创建的线程对象。 并销毁锁以及条件变量。

线程池的四个主要接口:

run_all_thread:调动线程池中的所有线程。 通过封装线程类的 create;

push_task:将任务push到我们的任务表中, 调用该任务的线程角色:生产者。

routine: 线程的回调 ,消费任务,调用该任务的线程角色:消费者。

join: 等待线程池中的每一个线程退出。

ThreadPool.hpp 代码如下: 

#ifndef __THREADPOOL_HPP_
#define __THREADPOOL_HPP_

#include <iostream>
#include <vector>
#include <queue>
#include <ctime>
#include "Thread.hpp"
#include "LockGuard.hpp"
#include "Task.hpp"
#include "Log.hpp"
#include "Date.hpp"
#include <unistd.h>

#define TNUM 3

namespace Xq
{
  template<class T>
  class thread_pool
  {
  public:
    pthread_mutex_t* get_lock()
    {
      return &_lock;
    }

    bool is_que_empty()
    {
      return _task_que.empty();
    }

    void wait_cond(void)
    {
      pthread_cond_wait(&_cond, &_lock);
    }

    T get_task(void)
    {
      T task = _task_que.front();
      _task_que.pop();
      return task;
    }
  public:
    // 线程池的构造函数负责实例化线程对象
    thread_pool(size_t tnum = TNUM)
      :_tnum(tnum)
    {
      for(size_t i = 0; i < _tnum; ++i)
      {
        // 这里只是创建了我们封装的线程对象
        // 事实上, 在这里,线程并没有真正被创建
        //_VPthread.push_back(new thread(i, routine, nullptr));
        // 为了解决静态成员函数无法访问非静态的成员方法和成员属性
        // 因此我们在这里可以传递线程池对象的地址,即this指针
        // 我们在这里是可以传递this指针的
        // 因为走到这里,此时的线程池对象已经存在 (空间已经开辟好了)
        _VPthread.push_back(new thread(i, routine, this));
      }
      pthread_mutex_init(&_lock, nullptr);
      pthread_cond_init(&_cond, nullptr);
    }

    // 启动所有线程池中的线程
    void run_all_thread(void)
    {
      for(const auto& vit : _VPthread)
      {
        vit->create();
      }
    }

    // 线程的回调
    /*
     * 首先 routine函数是线程池中的线程的回调函数
     * 任务是获取任务表的任务,处理任务(消费过程,消费任务)
     * 如果routine设计成了类的非静态成员函数,那么其第一个
     * 参数就是this指针, 与线程的回调函数类型不匹配。
     * 因此我们在这里可以将其设置为静态函数
     */

    /*
     * 当我们用static可以解决了上面的问题时(非静态成员函数的第一个参数是this指针)
     * 新问题又产生了,由于静态成员函数没有隐藏的this指针
     * 故静态成员方法无法访问成员属性和成员方法
     * 而我们的routine是用来消费任务表中的任务的
     * 换言之,它需要访问线程池中的成员属性(任务表)
     * 可是,此时的routine没有能力访问,如何解决这个问题?
     * 我们可以将线程池对象的this指针作为参数传递给我们封装的线程对象
     * 让特定线程通过this指针访问任务表
     */

    static void* routine(void* arg)
    {
      thread_info* info = static_cast<thread_info*>(arg);
      // 获得this指针
      thread_pool<T>* TP = static_cast<thread_pool<T>*>(info->get_arg());
      // 通过this指针访问任务表
      // 之前说了, 此时的任务表是一个临界资源
      // 因此,我们需要保证它的安全性以及访问合理性问题
      while(true)
      {
        T task;
        {
          lock_guard lg(TP->get_lock());
          while(TP->is_que_empty())
            TP->wait_cond();

          // 走到这里, 临界资源一定是就绪的
          // 即任务表中一定有任务
          // 获取任务
          task = TP->get_task();
        } 
        // 走到这里, 释放锁资源, 执行任务
        task(info->get_name());
      } 
      return nullptr;
    }

    // 生产任务, 相当于生产者, 我们想让主线程充当生产者角色
    // 生产过程本质上是向任务表生产任务
    // 在未来,当我们在向任务表push任务的时候,
    // 很有可能出现,(线程池中的) 若干个线程想要从任务表拿任务 (pop),
    // 因此,该任务表就是一个被多执行流共享的资源 (临界资源),
    // 因此我们必须要保障它的安全问题,因此我们就可以用锁保证它的安全性
    void push_task(const T& task)
    {
      lock_guard lg(&_lock); // RAII风格的锁
      _task_que.push(task);
      // 当我们将任务生产到任务表中后
      // 我们就可以唤醒相应的线程(线程池中的线程)来消费任务
      pthread_cond_signal(&_cond);
    }

    void join()
    {
      for(const auto& vit : _VPthread)
      {
        pthread_join(vit->get_tid(), nullptr);
      }
    }

    ~thread_pool()
    {
      pthread_mutex_destroy(&_lock);
      pthread_cond_destroy(&_cond);

      for(const auto& vit : _VPthread)
      {
        delete vit;
      }
    }

  private:
    size_t _tnum;  // 线程个数
    std::vector<thread*> _VPthread; // 线程对象表
    std::queue<T> _task_que; // 任务表
    pthread_mutex_t _lock;
    pthread_cond_t _cond;  
  };
}
#endif

模块三:互斥量的封装 (RAII风格)

在以前我们就说过,在这里就不再多余解释了。

LockGuard.hpp 代码如下:

#ifndef __LOCKGUARD_HPP_
#define __LOCKGUARD_HPP_

#include <pthread.h>

namespace Xq
{
  class mutex
  {
  public:
    mutex(pthread_mutex_t* lock):_lock(lock){}

    void lock(void){
      pthread_mutex_lock(_lock);
    }

    void unlock(void){
      pthread_mutex_unlock(_lock);
    }
  private:
    pthread_mutex_t* _lock;
  };
  class lock_guard
  {
  public:
    lock_guard(pthread_mutex_t* lock)
     :_mtx(lock)
    {
      _mtx.lock();
    }

    ~lock_guard()
    {
      _mtx.unlock();
    }
  private:
    mutex _mtx;
  };
}
#endif

模块四:任务的封装 

没有特别重要的点,不做多余解释。

Task.hpp 代码如下:

#ifndef __TASK_HPP_
#define __TASK_HPP_

#include <iostream>
#include <functional>
#include <string>
#include "Log.hpp"

namespace Xq
{
  class Task
  {
  public:
    Task() {}
    Task(int x, int y, std::function<int(int,int)> func)
      :_x(x)
       ,_y(y)
       , _func(func)
    {}

    void operator()(const std::string& name)
    {
      //std::cout << name << " 正在执行任务: " << _x << " + " << _y << " = " <<
        //_func(_x, _y)  << std::endl;
      LogMessage(NORMAL, "%s %s %d + %d = %d\n", name.c_str(), "正在执行任务", _x,   _y,  _func(_x,_y)) ;
    }

  private:
    int _x;
    int _y;
    std::function<int(int,int)> _func;
  };
}
#endif

模块五:日志的封装

va_list 是 C 语言中用于处理可变数量参数的一种机制。其位于 <stdarg.h> 头文件中。

va_start && va_end 是C语言提供的宏,用于处理可变参数的函数中。

void va_start(va_list ap, last_arg);
void va_end(va_list ap)

va_start:该宏用于初始化一个 va_list 类型的变量,以便开始访问可变数量的参数。其中,ap是一个va_list类型的变量,用于指向参数列表, last_arg 是最后一个固定的参数名。 va_start 宏会将 ap 定位到第一个可变参数的位置。

va_end: va_end 宏用于清理va_list 类型的变量, 在处理完可变参数列表后使用,其主要功能就是,清理 va_list 类型的变量,以确保进程的健壮性和内存管理的正确性。

 当有了上面的理解后,我们在看下面的函数:

#include <stdarg.h>

int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_list ap);

上面的这些函数都是用于处理可变参数的函数,我们以vprintf举例:

其允许将格式化的输出写入到标准输出流中 (显示器),其接收 va_list 类型的参数列表,当我们在使用前,一般需要用 va_start 初始化这个va_list变量, 确保它指向可变参数列表中的第一个参数。具体来说:

const char* format: 其是一个格式化字符串,用户可以指定输出的格式,类似于printf。

va_list ap:通过使用 va_start 宏初始化后,可以在 vprintf 中对可变参数进行访问和处理。

其他三个函数只不过将格式化输出的内容写入了不同的位置:

vfprintf:格式化的输出写入到指定的文件中。

vsprintf: 格式化的输出写入到字符串中。

vsnprintf: 安全地将格式化的输出写入到字符串中,避免溢出的风险,可以限制输出内容的最大长度,提高进程的健壮性。

示例如下:

void demo(const char* format, ...)
{
  va_list ap;
  va_start(ap, format);  
  vprintf(format,ap);
  va_end(ap);
}

int main()
{
  // 这里用户可以自定义信息, 与使用printf几乎一致。
  demo("%s %f %c %d\n", "haha", 3.14, 'x', 111 );
  return 0;
}

现象如下: 

在这之前,需要说明一下:

日志是需要日志级别的,不同的级别代表着不同的响应方式。

完整的日志功能,至少需要:日志等级、时间、日志内容、支持用户自定义信息 (用户还可以传递额外的信息 ) 。

Log.hpp 代码如下:

#pragma once

#include "Date.hpp"
#include <iostream>
#include <map>
#include <string>
#include <cstdarg>

#define LOG_SIZE 1024

// 日志等级
enum Level
{
  DEBUG, // DEBUG信息
  NORMAL,  // 正常
  WARNING, // 警告
  ERROR, // 错误
  FATAL // 致命
};

void LogMessage(int level, const char* format, ...)
{
// 如果想打印DUBUG信息, 那么需要定义DUBUG_SHOW (命令行定义, -D)
#ifndef DEBUG_SHOW
  if(level == DEBUG)
    return ;
#endif
  std::map<int, std::string> level_map;
  level_map[0] = "DEBUG";
  level_map[1] = "NORAML";
  level_map[2] = "WARNING";
  level_map[3] = "ERROR";
  level_map[4] = "FATAL";

  std::string info;
  va_list ap;
  va_start(ap, format);

  char stdbuffer[LOG_SIZE] = {0};  // 标准部分 (日志等级、日期、时间)
  snprintf(stdbuffer, LOG_SIZE, "[%s],[%s],[%s] ", level_map[level].c_str(), Xq::Date().get_date().c_str(),  Xq::Time().get_time().c_str());
  info += stdbuffer;

  char logbuffer[LOG_SIZE] = {0}; // 用户自定义部分
  vsnprintf(logbuffer, LOG_SIZE, format, ap);
  info += logbuffer;

  std::cout << info ;
  fflush(stdout);
  va_end(ap);
}

模块六:时间的封装

其核心目的:  将当前时间戳转化为当前日期 + 时间。

Date.hpp 代码如下:

#ifndef __DATE_HPP_
#define __DATE_HPP_

#include <iostream>
#include <ctime>

namespace Xq
{
  class Date
  {
  public:
    Date(size_t year = 1970, size_t month = 1, size_t day = 1)
      :_year(year)
       ,_month(month)
       ,_day(day)
      {}

    std::string& get_date()
    {
      size_t num = get_day();
      while(num--)
      {
        operator++();
      }
      char buffer[32] = {0};
      snprintf(buffer, 32, "%ld/%ld/%ld", _year,_month, _day);
      _data = buffer;
      return _data;
    }

  private:
    Date& operator++()
    {
      size_t cur_month_day = month_day[_month];
      if((_month == 2) && ((_year % 400 == 0 )|| (_year % 4 == 0 && _year % 100 != 0)))
        ++cur_month_day;
      ++_day;
      if(_day > cur_month_day)
      {
        _day = 1;
        _month++;
        if(_month > 12)
        {
          _month = 1;
          ++_year;
        }
      }
      return *this;
    }

   // 获得从1970.1.1 到 今天相差的天数
    size_t get_day()
    {
      return (time(nullptr) + 8 * 3600) / (24 * 60 * 60);
    }

  private:
    size_t _year;
    size_t _month;
    size_t _day;
    static int month_day[13];
    std::string _data;
  };

  int Date::month_day[13] = {
    0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
  };

  class Time
  {

  public:
    Time(size_t hour = 0, size_t min = 0, size_t second = 0)
      :_hour(hour)
       ,_min(min)
       ,_second(second)
    {}

    std::string& get_time()
    {
      size_t second = time(nullptr) + 8 * 3600;
      _hour = get_hour(second);
      _min = get_min(second);
      _second = get_second(second);

      char buffer[32] = {0};
      snprintf(buffer, 32, "%ld:%ld:%ld", _hour, _min, _second);
      _time = buffer;
      return _time;
    }


  private:

    size_t get_hour(time_t second)
    {
      //  不足一天的剩余的秒数
      size_t verplus_second = second % (24 * 60 * 60);
      return verplus_second / (60 * 60);
    }

    size_t get_min(time_t second)
    {
      // 不足一小时的秒数
      size_t verplus_second = second % (24 * 60 * 60) % (60 * 60);
      return verplus_second / 60;
    }

    size_t get_second(time_t second)
    {
      // 不足一分钟的秒数
      return second % (24 * 60 * 60) % (60 * 60) %  60;
    }
    
  private:
    size_t _hour;
    size_t _min;
    size_t _second;
    std::string _time;
  };
}

#endif

模块六:主函数

主线程负责定义线程池, 调度线程池中的线程, 主线程定义任务,生产任务,将任务推送到线程池对象中的任务表中 (扮演者生产者的角色)。最后,主线程需要等待新线程退出。

TestMain.cc 代码如下:

#include "ThreadPool.hpp"

int main()
{
  //std::cout << "hello thread pool " << std::endl;

  srand((unsigned int)time(nullptr) ^ getpid());
  
  Xq::thread_pool<Xq::Task>* TP = new Xq::thread_pool<Xq::Task>();

  TP->run_all_thread();

  while(true)
  {
    int x = rand() % 100;
    int y = rand() % 50;

    TP->push_task(Xq::Task(x, y, [](int x, int y)->int{
          return x + y;
          }));

 //   std::cout << "Main thread: " << "生产任务: " << x << " + " << y << " = " << " ? " << std::endl;

    LogMessage(NORMAL, "%s%d + %d = ?\n", "Main thread 生产任务:", x, y);
    sleep(1);
  }
  TP->join();
  
  return 0;
}

模块七: Makefile

TP:TestMain.cc
	g++ -o $@ $^ -std=gnu++11 -l pthread
.PHONY:clean
clean:
	rm -f TP

2. 单例模式

单例模式,我们在C++就已经说明,今天在这里,就不再是什么是单例模式的问题了。

我们以前说过,懒汉方式实现单例模式存在着线程安全问题,可是当初我们无法理解,但现在,我们就可以通过线程池这个实例用以理解了。

因此,我们想要对上面的线程池的封装加以更改,让其成为单例模式。

线程池的封装 (ThreadPool.hpp) 更改后的代码如下: 

namespace Xq
{
  template<class T>
  class thread_pool
  {
  public:
    pthread_mutex_t* get_lock() { /* 省略, 跟原来一致 */ }
    bool is_que_empty() { /* 省略, 跟原来一致 */  }
    void wait_cond(void) { /* 省略, 跟原来一致 */  }
    T get_task(void)  { /* 省略, 跟原来一致 */  }

  private:
    // 将构造私有化, 用于实现单例
    // 线程池的构造函数负责实例化线程对象
    thread_pool(size_t tnum = TNUM) :_tnum(tnum)  { /* 省略, 跟原来一致 */  }

  public:
    // 因为构造已被私有化,故需要我们显示提供一个可以获取该唯一实例的方法
    static thread_pool<T>* get_ptr_only_thread_pool(int num = TNUM)
    {
      if(_ptr_only_thread_pool == nullptr)
      {
        _ptr_only_thread_pool = new thread_pool(num);
      }
      return _ptr_only_thread_pool;
    }

    // 同时, 我们需要将拷贝构造、赋值都设为私有或者delete
    thread_pool(const thread_pool& copy) = delete;
  private:
    thread_pool& operator=(const thread_pool& copy);


  public:
    // 按照我们以前懒汉实现单例的方式, 我们可以内嵌一个垃圾回收类
    // 回收该唯一实例
    class resources_recovery
    {
    public:
      ~resources_recovery()
      {
        if(_ptr_only_thread_pool)
        {
          delete _ptr_only_thread_pool;
          _ptr_only_thread_pool = nullptr;
        }
      }
      static resources_recovery _auto_delete_ptr_only_thread_pool;
    };

  public:
    void run_all_thread(void) { /* 省略, 跟原来一致 */  }
    static void* routine(void* arg) { /* 省略, 跟原来一致 */  }
    void push_task(const T& task)  { /* 省略, 跟原来一致 */  }
    void join() { /* 省略, 跟原来一致 */  }
    ~thread_pool() { /* 省略, 跟原来一致 */  }

  private:
    std::vector<thread*> _VPthread; // 线程对象表
    size_t _tnum;  // 线程个数
    std::queue<T> _task_que; // 任务表
    pthread_mutex_t _lock;
    pthread_cond_t _cond;  
    // 因为构造已被私有化, 故我们需要定义一个静态对象的指针
    static thread_pool<T>* _ptr_only_thread_pool;
  };

  // 初始化该唯一实例
  template<class T>
  thread_pool<T>* thread_pool<T>::_ptr_only_thread_pool = nullptr;

  // 定义一个静态成员变量,进程结束时,系统会自动调用它的析构函数从而释放该单例对象
  template<class T>
  typename thread_pool<T>::resources_recovery thread_pool<T>::resources_recovery::_auto_delete_ptr_only_thread_pool;
}

主函数 (TestMain.cc) 更改后的代码如下:

#include "ThreadPool.hpp"

int main()
{
  srand((unsigned int)time(nullptr) ^ getpid());
  
  // 通过 get_ptr_only_thread_pool 接口获得线程池唯一实例对象
  Xq::thread_pool<Xq::Task>* only_target = Xq::thread_pool<Xq::Task>::get_ptr_only_thread_pool();
  
  only_target->run_all_thread();
  while(true)
  {
    int x = rand() % 100;
    int y = rand() % 50;
    only_target->push_task(Xq::Task(x, y, [](int x, int y)->int{
          return x + y;
          }));
    LogMessage(NORMAL, "%s%d + %d = ?\n", "Main thread 生产任务:", x, y);
    sleep(1);
  }
  only_target->join();
  return 0;
}

有了上面的代码,我们就将线程池实现了单例 (懒汉模式)。

如果我们假设一种场景:如果申请单例这个过程也是多线程执行的呢?换言之,多个执行流同时申请这个单例呢?

我们看看申请单例的核心代码:

static thread_pool<T>* get_ptr_only_thread_pool(int num = TNUM)
{
	if (_ptr_only_thread_pool == nullptr)
	{
		_ptr_only_thread_pool = new thread_pool(num);
	}
	return _ptr_only_thread_pool;
}

我们学到这里了,应该可以理解,执行流在被调度的时候,任何时刻都有可能发生上下文切换。

那么我们假设一种场景:A、B两个线程同时要申请这个唯一实例,假设A线程先被CPU调度:

  1. A 线程判断唯一实例为空,准备创建新实例;
  2. 此时发生上下文切换,A 线程被挂起,B 线程开始运行,并成功创建了该唯一实例;
  3. 当再次切换回 A 线程时,A 线程继续执行,此时 A 线程可能会误以为唯一实例仍为空,再次尝试创建新实例,导致违反唯一实例的要求。

这种问题的根源就是:这个唯一实例是一个临界资源,故我们要保证它的安全性,我们可以通过互斥锁的串行执行,保证获取唯一实例的安全性。

因此更改后的代码如下:

static thread_pool<T>* get_ptr_only_thread_pool(int num = TNUM)
{
	{
        // 通过加锁保证这个唯一实例的安全性
		lock_guard lock(&_only_target_lock);
		if (_ptr_only_thread_pool == nullptr)
		{
			_ptr_only_thread_pool = new thread_pool(num);
		}
	}
	return _ptr_only_thread_pool;
}

当我们上面用互斥量保证了该唯一实例的安全性,即保证了单例模式的安全问题。但是仍旧存在问题。

场景: 假设有很多个线程,都要获取这个唯一实例对象。

当第一个线程被调度的时候,获取到锁,进入临界区,获取这个唯一实例,那么此时这个唯一实例就不为空了。

因此当接下来的众多线程都会做着一样的工作, 申请释放锁,但无法获得这个唯一实例,甚至由于互斥锁会导致执行流串行执行,降低了并发能力,从而导致执行效率被降低。

上面的问题虽然不会产生错误,但是不太合理。

为了解决这个问题,我们可以使用双重检查锁定(Double-Checked Locking)模式,在保证线程安全的同时尽可能减少锁的持有时间。这种模式可以在第一次访问时使用锁保护,而在后续访问时避免不必要的加锁释放锁操作

因此我们更改后的代码如下:

static thread_pool<T>* get_ptr_only_thread_pool(int num = TNUM)
{
    // 双重检查, 避免不必要的加锁和释放锁
	if (_ptr_only_thread_pool == nullptr)
	{
		lock_guard lock(&_only_target_lock);
		if (_ptr_only_thread_pool == nullptr)
		{
			_ptr_only_thread_pool = new thread_pool(num);
		}
	}
	return _ptr_only_thread_pool;
}

通过这种方式,第一次访问时会加锁创建唯一实例,之后的访问会避免重复加锁释放锁操作,进一步提高了性能。双重检查锁定模式是一种常见的优化手段,可以在保证线程安全的同时提高进程的并发性能。

至此,我们更改后的 ThreadPool.hpp 代码如下:

namespace Xq
{
  template<class T>
  class thread_pool
  {
  public:
    pthread_mutex_t* get_lock() { // 省略, 跟原来一致  }
    bool is_que_empty() { // 省略, 跟原来一致  }
    void wait_cond(void) { // 省略, 跟原来一致  }
    T get_task(void)  { // 省略, 跟原来一致  }

  private:
    // 将构造私有化, 用于实现单例
    // 线程池的构造函数负责实例化线程对象
    thread_pool(size_t tnum = TNUM) :_tnum(tnum)  { // 省略, 跟原来一致  }

  public:
    // 因为构造已被私有化,故需要我们显示提供一个可以获取该唯一实例的方法
    static thread_pool<T>* get_ptr_only_thread_pool(int num = TNUM)
    {
      // 双重检查锁定模式
      if(_ptr_only_thread_pool == nullptr)
      {
        // 这把锁保证唯一实例
        lock_guard lock(&_only_target_lock);
        if(_ptr_only_thread_pool == nullptr)
        {
          _ptr_only_thread_pool = new thread_pool(num);
        }
      }
      return _ptr_only_thread_pool;
    }

    // 同时, 我们需要将拷贝构造、赋值都设为私有或者delete
    thread_pool(const thread_pool& copy) = delete;
  private:
    thread_pool& operator=(const thread_pool& copy);


  public:
    // 按照我们以前懒汉实现单例的方式, 我们可以内嵌一个垃圾回收类
    // 回收该唯一实例
    class resources_recovery
    {
    public:
      ~resources_recovery()
      {
        if(_ptr_only_thread_pool)
        {
          delete _ptr_only_thread_pool;
          _ptr_only_thread_pool = nullptr;
        }
      }
      static resources_recovery _auto_delete_ptr_only_thread_pool;
    };

  public:
    void run_all_thread(void) { // 省略, 跟原来一致  }
    static void* routine(void* arg) { // 省略, 跟原来一致  }
    void push_task(const T& task)  { // 省略, 跟原来一致  }
    void join() { // 省略, 跟原来一致  }
    ~thread_pool() { // 省略, 跟原来一致  }

  private:
    std::vector<thread*> _VPthread; // 线程对象表
    size_t _tnum;  // 线程个数
    std::queue<T> _task_que; // 任务表
    pthread_mutex_t _lock;
    pthread_cond_t _cond;  

    // 因为构造已被私有化, 故我们需要定义一个静态对象的指针
    static thread_pool<T>* _ptr_only_thread_pool;

    // 这把锁保证获取唯一实例的安全性
    static pthread_mutex_t _only_target_lock;
  };

  // 初始化该唯一实例
  template<class T>
  thread_pool<T>* thread_pool<T>::_ptr_only_thread_pool = nullptr;

  // 定义一个静态成员变量,进程结束时,系统会自动调用它的析构函数从而释放该单例对象
  template<class T>
  typename thread_pool<T>::resources_recovery thread_pool<T>::resources_recovery::_auto_delete_ptr_only_thread_pool;
  
  // 初始化这把静态锁 (保证获取唯一实例的安全)
  template<class T>
  pthread_mutex_t thread_pool<T>::_only_target_lock = PTHREAD_MUTEX_INITIALIZER;
}

 3. STL, 智能指针和线程安全

3.1. STL是否是线程安全的?

STL不保证线程安全。 STL的设计初衷就是将性能挖掘到极致,而不是为了解决多线程并发访问的问题,且一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。

因此,STL默认不是线程安全的, 如果在多线程场景下使用, 需要调用者自身保证线程安全。

 3.2. 智能指针是否是线程安全的?

对于 unique_ptr, 由于只是在当前代码块范围内生效,因此不涉及线程安全问题。 但有些场景下, 如果 unique_ptr 和其他容器一并使用,可能设计线程安全问题。因此,我们只能说,一般情况下, unique_ptr 不涉及线程安全问题。

对于 shared_ptr, 多个对象共用一个引用计数变量, 所以会存在线程安全问题。 但是标准库实现的时候考虑到了这一点, 解决方案是: 基于原子操作如 ( CAS,Compare-And-Swap ) 的方式来确保引用计数的安全性。

总的来说,智能指针自身是线程安全的,但由于智能指针未来是指向或者引用某个对象的,而这个对象不是线程安全的。

4. 其它常见的锁

悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁,当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

4.1. 自旋 && 自旋锁 --- spin lock

首先,我们谈一下我们以前学过的互斥锁。

在多线程场景下,多个执行流通过加锁方式进入临界区访问临界资源,当锁被某个线程占有的时候,其他线程想要进入临界区,而由于要进入临界区,就要先获得这把锁,因此,我们以前说过,这些线程会被挂起阻塞,更具体地讲就是:这些 task_struct 对象会等待这把锁资源,同时这些执行流状态被设置为非R状态 (挂起等待)。

而为了理解自旋这个概念,我们列出两个场景: 

场景一:

小A和小B是一届的两个同学,小B是努力型学霸 (笔记做得特别好,学习成绩名列前茅),小A成天旷课,上网打游戏。 

临界期末考试 (比如考操作系统),小A就急坏了,就来找小B,想要借小B的笔记用一用,用完再还给小B,于是小A就通过电话联系了小B, 小B一听, 可以啊,但是我还有一些地方没有准备好,你能等我一个小时吗?小A就答应了,正常情况下,小A一听还有一个小时,自然不会一直在这里干等小B,于是小A就对小B说, 这样,你不还有一个小时吗? 我先去上个网,打几把游戏,你到时间了,叫我一声,我在过来到宿舍楼下等你,怎么样? 小B就同意了。

场景二:

上次的操作系统考试,由于小A借了小B的笔记,啥知识点应有尽有,因此小A考完感觉非常良好,这不,又临界考试 (计算机网络)。

小A就又来找小B,问小B能不能借笔记用一用, 小B一听,就同意了,并且说,我马上下楼给你。 此时,小A会不会说,那行,我先去上个网,你下楼了叫我一声,我在过来。 答案是:不可能,如果小A这样说了,小B一定说小A有病,我马上都下楼了,你还要去上网,可能你在去的路上我就可能到楼下了,因此,正常情况下,小A只会说,那行,我在你楼下等你。

综合上面两种情况,提出一个问题:

是什么决定了小A同学在楼下等待的策略? 是先去上网,再来找小B? 还是在楼下原地等待呢?

答案是: 小B同学 (临界资源) 就绪的时间

类比到我们的线程:

当执行流在获取锁资源,进入临界区访问临界资源时,如果在临界区访问时间长 (临界资源就绪时间长),那么其他执行流如果要访问这把锁,那么应该要挂起等待,就好比上面,先去上一会网,再来访问这把锁资源。

当执行流在获取锁资源,进入临界区访问临界资源时,如果在临界区访问时间短 (临界资源就绪时间短),那么其他执行流如果要访问这把锁,那么就不应该让这些线程挂起等待。

接着场景二,故事继续,小B说1分钟就下楼了,小A就在楼下等他。 可是一分钟过去了,小A还是没有看到小B,就有给小B打了个电话,小B说,快了快了。 于是小A继续等。 一分钟又过去了,小A又给小B打电话,小B说: 马上到了。 就这样重复了好几次。 

在上面这段例子中,小A为什么不先去上个网呢? 因为小B说他马上下楼了; 可小A为什么轮询式的给小B打电话呢? 因为小A要确认小B的状态。

而上面这种检测方式我们就称之为自旋式的检测临界资源是否就绪

自旋锁本质就是通过不断 (轮询) 检测锁状态来判断临界资源是否就绪的方案

什么时候使用自旋锁呢? 我们可以根据临界资源就绪的时间来判定。

在并发编程中,当多个线程竞争同一把锁时。

如果临界区访问时间较短,即锁的占有时间短暂,那么其他线程就可以选择自旋等待来竞争锁,避免频繁地挂起和唤醒线程 (避免线程频繁地上下文切换),从而提高效率。

而当临界区访问时间较长时,其他线程应该及时放弃自旋等待,转而挂起等待,避免资源浪费和性能下降。

4.2. 自旋锁的接口介绍:

初始化自旋锁:

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

pshared参数:

PTHREAD_PROCESS_PRIVATE: 表示自旋锁是进程私有的,只能被创建它的进程中的线程使用。

PTHREAD_PROCESS_SHARED:表示自旋锁是可跨进程共享的,可以被同一进程中的不同线程或不同进程中的线程使用。

销毁自旋锁,释放自旋锁资源:

int pthread_spin_destroy(pthread_spinlock_t *lock);

加锁操作:

int pthread_spin_lock(pthread_spinlock_t *lock);

int pthread_spin_trylock(pthread_spinlock_t *lock);

pthread_spin_lock: 加锁操作。如果自旋锁已被占用,则当前线程将自旋等待直到获取自旋锁。

pthread_spin_trylock:尝试加锁,如果自旋锁已被占用,则立即返回失败,不会进入自旋等待状态。

返回值: 上面的函数成功返回0,失败还会错误码。

解锁操作,释放自动锁:

int pthread_spin_unlock(pthread_spinlock_t *lock);

4.3. 自旋锁的使用

在使用自旋锁时,需要注意的是,自旋锁适合用于资源占用时间短、竞争情况不频繁的场景,避免长时间占用 CPU 或者造成 CPU 浪费。另外,要确保在解锁前先加锁,以保证线程安全性。

我们就以前我们写的抢票逻辑来举例:

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

#define PTHREAD_NUM 3
#define BUFFER_SIZE 32
 
int ticket = 1000;
 
class PTHREAD_INFO
{
public:
  PTHREAD_INFO(const std::string& name, pthread_spinlock_t* Plock)
    :_name(name)
     ,_Plock(Plock)
  {}
public:
  std::string _name;
  pthread_spinlock_t* _Plock;
};
 
void* GetTicket(void* arg)
{
  PTHREAD_INFO* info = static_cast<PTHREAD_INFO*>(arg);
  while(true)
  {
    pthread_spin_lock(info->_Plock);
    if(ticket > 0)
    {
      usleep(1000);
      std::cout << info->_name << " get a ticket " << ticket << std::endl;
      ticket--;
      pthread_spin_unlock(info->_Plock);
    }
    else
    {
      pthread_spin_unlock(info->_Plock);
      break;
    }
    usleep(rand() % 500);
  }
  delete info;
  return nullptr;
}
 
void Test1(void)
{
  pthread_t tid[PTHREAD_NUM];
  // 定义局部自旋锁
  pthread_spinlock_t myspinlock;
  // 初始化局部自旋锁, 且线程间共享
  pthread_spin_init(&myspinlock, PTHREAD_PROCESS_PRIVATE);  
  char buffer[BUFFER_SIZE] = {0};
  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    snprintf(buffer, BUFFER_SIZE, "%s-%ld", "thread", i + 1);
    PTHREAD_INFO* info = new PTHREAD_INFO(buffer, &myspinlock);
    pthread_create(tid + i, nullptr, GetTicket, static_cast<void*>(info));
  }
  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }
  // 局部自旋锁需要我们手动释放
  pthread_spin_destroy(&myspinlock);
}
 
int main()
{
  srand((size_t)time(nullptr));
  Test1();
  return 0;
}

4.2. 读写锁

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。

读写锁,我们用分析生产者消费者模型的思路进行理解: 即读写锁也可以用321原则理解。

三种关系: 读者和读者 (共享关系)、 写者和写者  (互斥)、读者和写者 (同步、互斥)。

两个角色, 读者、写者。

一个交易场所,本质就是用户自己定义的数据缓冲区。

读者写者问题 vs 生产者消费者模型的本质区别是什么?

消费者会取走数据,因此消费者之间才需要互斥。

读者不会取走数据 (只存在拷贝),因此读者之间不需要互斥。

读写锁的应用场景:数据被读取的频率非常高, 而被修改的频率特别低。

当没有锁的时候: 读锁请求和写锁请求都可以满足。

当有读锁的时候: 由于读者和读者是共享的,因此读锁请求可以满足;而由于读者和写者是互斥的,因此写锁请求会被阻塞。

当有写锁的时候: 由于读者和写者是互斥的,因此读锁请求会被阻塞;而由于写者和写者是互斥的,因此写锁请求也会被阻塞。

写锁优先级高,当读锁和写锁同时到来的时候,优先获得写锁。

总结: 写独占,读共享,写锁优先级高

4.2.1. 读写锁的接口:

// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
        const pthread_rwlockattr_t *restrict attr);

// 释放读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

//加锁:
// 读者加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 写者加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

// 解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值