muduo库学习之C++多线程系统编程精要01——基本线程原语的选用、Linux上的线程标识、善用__thread关键字、Linux新增系统调用的启示

东阳的学习笔记

学习多线程面临的最大的思维转变有两点:

  1. 当前线程可能随时会被切出去,或者说被抢占了。
  2. 多线程程序中事件的发生顺序不再有全局统一的先后关系

多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep)来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能够看到其他线程事件的结果。无论线程执行得快与慢(被操作系统切换出去的越多执行地越慢), 程序都应该能够正常工作。`

一、基本线程原语的选用

  • 我认为用C/C++编写跨平台(只针对POSIX操作系统)的多线程程序不是普遍的需求,因此我们只谈现代Linux(2004年Linux 2.6内核发布之后,NPTL线程库)下的多线程编程

  • POSIX threads的函数有110多个, 真正常用的不过十几个

    。而且在C++程序中通常会有更为易用的wrapper,不会直接调用Pthreads函数。这11个最基本的Pthreads函数是:

    • **2个:**线程的创建和等待结束(join)。封装为muduo::Thread
    • **4个:**mutex的创建、销毁、加锁、解锁。封装为 muduo::MutexLock
    • **5个:**条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition
  • 这些封装class都很直截了当,加起来也就一两百行代码,却已经构成了多线程编程的全部必备原语。用这三样东西(thread、mutex、 condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如mutex::ThreadPool和mutex::CountDownLatch等(见前面“线程同步精要”文章)

  • 除此之外,

    Pthreads还提供了其他一些原语,有些是可以酌情使用的,有些则是不推荐使用的:

    • 可以酌情使用的有:
      • pthread_once,封装为muduo::Singleton。其实不如直接用全局 变量
      • pthread_key*,封装为muduo::ThreadLocal。可以考虑用 __thread替换之
    • 不建议使用:
      • pthread_rwlock,读写锁通常应慎用。muduo没有封装读写锁,这是有意的
      • sem_*,避免用信号量(semaphore)。它的功能与条件变量重合,但容易用错
      • pthread_{cancel, kill}。程序中出现了它们,则通常意味着设计出了问题
  • 不推荐使用读写锁的原因是:它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的mutex相比,它实际上降低了性能。另外,写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的

  • 多线程系统编程的难点不在于学习线程原语(primitives),而在于理解多线程与现有的C/C++库函数和系统调用的交互关系,以进一步学习如何设计并实现线程安全且高效的程序

二、Linux上的线程标识

  • 关于线程标识,还可以参阅:https://blog.csdn.net/qq_41453285/article/details/89293330

pthread_t(不适合用作程序中对线程的标识符

  • POSIX threads库提供了pthread_self函数用于返回当前进程的标识符,其类型为pthread_t
  • pthread_t****不一定是一个数值类型(整数或指针),也有可能是一个结构体,****因此Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等
  • 这就带来一系列问题,包括:
    • 无法打印输出pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程的id
    • **无法比较pthread_t的大小或计算其hash值,**因此无法用作关联容器的key
    • 无法定义一个非法的pthread_t值,用来表示绝对不可能存在的线程id,因此MutexLock class没有办法有效判断当前线程是否已经持有本锁
    • pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比方说在/proc文件系统中找不到pthread_t对应的task
  • 另外,glibc的Pthreads实现实际上把pthread_t用作一个结构体指针 (它的类型是unsigned long),指向一块动态分配的内存,而且这块内存是反复使用的。这就造成pthread_t的值很容易重复。Pthreads只保证同一进程之内,同一时刻的各个线程的id不同;不能保证同一进程先后多个线程具有不同的id,更不要说一台机器上多个进程之间的id唯一性了
  • 例如,下面这段代码中先后两个线程的标识符是相同的:
#include <stdio.h>
#include <pthread.h>
void *threadFunc(void*){
     
     }
int main()
{
     
     
    pthread_t t1,t2;
    pthread_create(&t1,NULL,threadFunc,NULL); //创建线程
    printf("%lx\n",t1);                       //打印线程id
    pthread_join(t1,NULL);                    //阻塞等待t1线程结束
    pthread_create(&t2,NULL,threadFunc,NULL);
    printf("%lx\n",t2);
    pthread_join(t2,NULL);
    return 0;
}
  • 一次运行结果如下:
    img

  • 因此,pthread_t并不适合用作程序中对线程的标识符

pid_t

  • 在Linux上,我建议使用gettid系统调用的返回值作为线程id
  • 这么做的好处有:
    • 它的类型是pid_t,其值通常是一个小整数(最大值是/proc/sys/kernel/pid_max,默认值是32768),便于在日志中输出
    • 在现代Linux中,它直接表示内核的任务调度id,因此在/proc文件系统中可以轻易找到对应项:/proc/tid或/prod/pid/task/tid
    • **在其他系统工具中也容易定位到具体某一个线程,**例如在top中我们可以按线程列出任务,然后找出CPU使用率最高的线程id,再根据程序日志判断到底哪一个线程在耗用CPU
    • **任何时刻都是全局唯一的,**并且由于Linux分配新pid采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程id
    • **0是非法值,**因为操作系统第一个进程init的pid是1
  • 但是glibc并没有封装这个系统调用,需要我们自己实现。封装gettid很简单,但是每次都执行一次系统调用似乎有些浪费,如何才 能做到更高效呢?
  • muduo::CurrentThread::tid()采取的办法是:
    • 用**__thread变量来缓存gettid的返回值**,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果(这个做法是受了glibc封装getpid()的启发),效率无忧
    • 多线程程序在打日志的时候可以在每一条日志消息中包含当前线程的id,不必担心有效率损失。读者有兴趣的话可以对比一下boost::this_thread::get_id()的实现效率
  • 还有一个小问题,万一程序执行了fork,,那么子进程会不会看到stale的缓存结果呢?解决办法是用pthread_atfork()注册一个回调,用于清空缓存的线程id。具体代码见muduo/base/CurrentThread.h和Thread.cc

三、善用__thread关键字

  • __thread关键字概述:
    • __threadGCC内置的线程局部存储设施(thread local storage)
    • 它的实现非常高效,比pthread_key_t快很多,见Ulrich Drepper写的 《ELF Handling For Thread-Local Storage》(http://www.akkadia.org/drepper/tls.pdf)
  • __thread变量的存取效率可与全局变量相比:

img

_thread使用规则

  • 只能用于修饰POD类型,不能修饰class类型, 因为无法自动调用构造函数和析构函数
  • __thread可以用于修饰全局变量、函数内的静态变量,但是不能用于修饰函数的局部变量或者class的普通成员变量
  • 另外,__thread变量的初始化只能用编译期常量
  • 例如:

img

_thread的用途

  • __thread变量是每个线程有一份独立实体,各个线程的变量值互不干扰。除了这个主要用途,它还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量
  • muduo代码中用到了好几处 __thread,简单列举如下:
    • muduo/base/Logging.cc:缓存最近一条日志时间的年月日时分秒, 如果一秒之内输出多条日志,可避免重复格式化。另外, muduo::strerror_tl把strerror_r(3)做成如同strerror(3)一样好用,而且是线 程安全的
    • muduo/base/ProcessInfo.cc:用线程局部变量来简化::scandir(3)的使用
    • muduo/base/Thread.cc:缓存每个线程的id
    • muduo/base/EventLoop.cc:用于判断当前线程是否只有一个EventLoop 对象
  • 以上例子都是__thread修饰POD类型的变量
  • 如果要用到thread local的class对象:
    • 可以考虑使用 muduo::ThreadLocal和muduo::ThreadLocalSingleton这两个class, 它能在线程退出时销毁class对象
    • 例如用examples/asio/chat/server_threaded_highperformance.cc用ThreadLocalSingleton来保存每个EventLoop线程所管辖的客户连接,以实现高效的消息转发(可以参阅后面“muduo编程示例之“串并转换”连接服务器机器自动化测试”文章)

四、Linux新增系统调用的启示

  • ****本节的内容还可以参阅陈硕的一篇同名博客:****https://blog.csdn.net/Solstice/article/details/5327881。省略了signalfd、timerfd、 eventfd等内容,对此感兴趣的读者可阅读原文

  • 大致从Linux内核2.6.27起,凡是会创建文件描述符的syscall一般都增加了额外的flags参数

    ,可以直接指定O_NONBLOCK和 FD_CLOEXEC,例如:

    • accept4 - 2.6.28
    • eventfd2 - 2.6.27
    • inotify_init1 - 2.6.27
    • pipe2 - 2.6.27
    • signalfd4 - 2.6.27
    • timerfd_create - 2.6.25
  • 以上6个syscall,除了最后一个是2.6.25的新功能,其余的都是增强原有的调用,把数字尾号去掉就是原来的syscall

O_NONBLOCK

  • O_NONBLOCK的功能是开启“非阻塞IO”,而文件描述符默认是阻塞的
  • 这些创建文件描述符的系统调用能直接设定O_NONBLOCK选项,其或许能反映当前Linux(服务端)开发的风向,即在前面“多线程服务器的编程模型”文章里推荐的one loop per thread +(non-blocking IO with IO multiplexing)
  • 从这些内核改动来看,non-blocking IO已经主流到让内核增加syscall以节省一 次fcntl调用的程度了

FD_CLOEXEC

  • 另外,

    以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:

    • dup3 - 2.6.27
    • epoll_create1 - 2.6.27
    • socket - 2.6.27
  • FD_CLOEXEC的功能**是让程序exec()时,进程会自动关闭这个文件描述符。**而文件描述默认是被子进程继承的(这是传统Unix的一种典型IPC,比如用pipe在父子进程间单向通信)

  • 以上8个新syscall都允许直接指定FD_CLOEXEC,或许说明fork()的主要目的已经不再是创建worker process并通过共享的文件描述符和父进程保持通信,而是像Windows的CreateProcess那样创建“干净”的进程 (fork()之后立刻exec()),其与父进程没有多少瓜葛

  • 为了回避 fork()+exec()之间文件描述符泄漏的race condition,这才在几乎所有能新建文件描述符的系统调用上引入了FD_CLOEXEC参数,参见Ulrich Drepper的短文《Secure File Descriptor Handling》(http://udrepper.livejoumal.com/20407.html)

  • 通过以上两个flags看来:
    • 说明Linux服务器开发的主流模型正在由fork()+worker processes模型转变为前面文章推荐的多线程模型
    • fork()的使用频度会大大降低,将来或许只有专门负责启动别的进程的“看门狗程 序”才会调用fork(),而一般的网络服务器程序不会再fork()出子进程了
    • 原因之一是,fork()一般不能在多线程程序中调用(参阅后面的“多线程与fork()”文章)

猜你喜欢

转载自blog.csdn.net/qq_22473333/article/details/113519156