多线程编程

来源:互联网 发布:域名注册 便宜 编辑:程序博客网 时间:2024/06/06 10:38

Linux线程概述

内核线程和用户线程

线程是程序中完成独立任务的完整执行序列。即一个可调度的实体,根据运行环境和调度者身份,线程分为内核线程和用户线程

  • 内核线程:在有的系统上也称为LWP(轻量级进程),运行在内核空间,由内核调度;
  • 用户线程:运行在用户空间,由线程库调度;

当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程,可见,内核线程相当于用户线程运行的“容器”。

一个进程可以拥有M个内核线程和N个用户线程,其中M≤N,并且在一个系统的所有进程中,M和N的比值都是固定的,按照M:N的取值,线程的实现方式可分为三种模式:

  • 完全在用户空间实现;
  • 完全由内核调度;
  • 双层调度;

完全在用户空间实现的线程无须内核的支持,内核甚至不知道这些线程的存在。线程库负责管理所有执行线程,比如线程的优先级、时间片等。线程库利用longjmp 来切换现成的执行,使它们看起来像是“并发”执行的。但实际上内核仍然是把整个进程作为最小单位来调度的。换句话说,一个进程的所有执行线程共享该进程的时间片,它们对外表现出相同的优先级,因此,对于这种实现方式而言,M个用户线程对应1个内核线程,而该内核线程就是进程本身。完全在用户空间实现的线程:

  • 优点是:创建和调度线程无须内核的干预,因此速度相当快,并且它不占用额外的内核资源,所以即使创建了很多线程,也不会对系统性能造成明显的影响。

  • 缺点是:对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的。此外线程的优先级只对同一个进程的线程有效,比较不同进程中的线程优先级是无意义的。

完全由内核调度的模式将创建、调度线程的任务都交给了内核,运行在用户空间的线程库无须执行管理任务。较早的Linux内核对内核线程的控制能力有限,线程库通常还要提供额外的控制,尤其是线程同步机制,不过现代Linux内核大大增强了对线程的控制,完全由内核调度的这种实现方式满足M:N = 1:1,即1个用户空间线程对应1个内核线程;


双层调度模式是前两种实现模式的混合体:内核调度M个内核线程,线程库调度N个用户线程,这样不但不会消耗过多的内核资源,而且线程切换速度也较快,同时它可以充分利用多处理器的优势;

线程操作函数

#include <pthread.h>int pthread_create(pthread_t* thread,    //unsigned long 整形类型                   const pthread_attr_t* attr,  //线程属性                   void* (*start_routine)(void *),//线程运行函数                   void* arg);//函数的参数void pthread_exit(void* retval);  //retval 传递退出信息int pthread_join(pthread_t thread, void** retval); //阻塞等待线程结束int pthread_cancel(pthread_t thread);  //取消线程,接收到取消请求的目标线程可以决定是否被取消以及如何取消int pthread_detach(pthread_t thread);  //分离释放线程

线程相关函数详解

同步函数

/*   信号量   */#include <semaphore.h>int sem_init(sem_t* sem,             int pshared,   //为0表示是当前进程的局部信号量             unsigned int value);  //初始化信号量int sem_destroy(sem_t* sem);int sem_wait(sem_t* sem);int sem_post(sem_t* sem);/*   互斥锁   */#include<pthread.h>int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* mutexattr);int pthread_mutex_destroy(pthread_mutex_t* mutex);int pthread_mutex_lock(pthread_mutex_t* mutex);int pthread_mutex_unlock(pthread_mutex_t* mutex);//这样也可以初始化锁pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;/*   条件变量   */int pthread_cond_init(pthread_cond_t* cond, pthread_condattr_t* cond_attr);int pthread_cond_destroy(pthread_cond_t* cond);int pthread_cond_signal(pthread_cond_t* cond);int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

线程安全的定义

一个线程安全的类应满足以下三个条件:

  • 多个线程同时访问时,其表现出正确的行为;
  • 无论OS如何调度这些线程,无论这些线程的执行顺序如何交织;
  • 调用端代码无须额外的同步或其他协调动作;

根据这个定义,C++标准库里面的大多数class都不是线程安全的,包括string、vector、map等,因为这些类需要外部加锁才能供多个线程同时访问。
间不要泄露this指针。**

线程同步原则

  1. 尽量最低限度的共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露,如果要暴露,优先使用immutable对象;实在不行才暴露可修改的对象,并用同步措施来充分保护它;
  2. 其实是使用高级的并发编程构件,如TaskQueue,Producter-Consumer Queue等等;
  3. 最后不得已必须使用底层同步原语时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量;

Singleton实现

template<typename T>class Singleton{public:    static T& instance(){        pthread_once(&ponce_, &Singleton::init);        return *value_;    }private:    Singleton();    ~Singleton();    static void init(){        value_ = new T();    }   private:    static pthread_once_t ponce_;    static T* value_;};template<typename T>pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;template<typename T>T* Singleton<T>::value_ = NULL;

sleep()不是同步原语

生产代码中线程的等待可分为两种:

  • 等待资源可用(要么等在select/poll/epoll_wait上,要么等在条件变量上);
  • 等待进入临界区(等在mutex)以便读写共享数据;

后一种等待通常非常短,否则程序的性能和伸缩性就会有问题;

在程序的正常执行中,如果需要等待一段已知的时间,应该往event loop里注册一个timer,然后在timer的回调函数里接着干活,因为线程是个珍贵的共享资源,不能轻易浪费(阻塞也是浪费)。如果等待某个事件发生,那么应该采用条件变量或I/O事件回调,不能用sleep来轮询,不要采用这样的业余做法:

while(true){    if(!dataAvailable)        sleep(some_time);    else        consumeData();}

如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是设计出了问题。等待某个事件发生,正确的做法是用select()等价物或Condition,亦或高层同步工具,在用户态做轮询是低效的。

适用多线程程序的场景

多线程的应用场景是:提高响应速度,让IO和“计算”相互重叠,降低latency。虽然多线程不能提高绝对性能,但能提高平均响应性能;

一个程序要做成多线程的,大致要满足:

  • 有多个CPU可用;
  • 线程间有共享数据,即内存中的全局状态;
  • 共享数据是可以修改的,而不是静态的常量表;
  • 提供非均质的服务,即,事件的响应有优先级差异,可以用专门的线程来处理优先级高的事件,防止优先级反转;
  • latency和throughput同样重要,不是逻辑简单的IO bound或CPU bound程序,换言之,程序要有相当的计算量;
  • 利用异步操作,比如logging,无论往磁盘写log file,还是往log server发送消息都不应该阻塞critical path;
  • 能scale up,一个好的多线程程序应该能享受增加CPU数目带来的好处;
  • 具有可预测的性能,随着负载增加,性能缓慢下降,超过某个临界点之后急速下降,线程数目一般不随负载变化;
  • 多线程能优先的划分责任和功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是把所有逻辑都塞到一个event loop里,不同类别的时间之间相互影响。

示例:

假设要管理一个Linux服务器机群,机群里面有8个计算节点,1个控制节点,机器的配置都是一样的,双路四核CPU,千兆网互联。现在需要编写一个简单的机群管理软件,这个软件由三部分组成:

  1. 运行在控制节点的master,这个程序监视并控制整个机群的状态;
  2. 运行在每个计算节点的salve,负责启动和终止job,并监控本机的资源;
  3. 供最终用户使用的client命令行,用于提交job;

可知,slave是个“看门狗进程”,它会启动别的job进程,因此必须是个单线程进程,另外它不应该占据太多CPU资源,这也适合单线程模型,master应该是个多线程程序:

  • 它占用一个8核机器,如果用单线程,浪费87.5%的CPU资源;
  • 整个机群的状态应该完全放在内存中,这些状态是共享且可变的;
  • master的主要性能指标不是throughput,而是latency,即尽快的响应各种事件,它几乎不会出现把IO或CPU跑满的情况;
  • master监控的事件有优先级区别,一个程序正常运行结束和异常崩溃的处理优先级不同,计算节点的磁盘满了和机箱温度过高这两种报警条件的优先级也不相同,如果用单线程,可能出现优先级反转;
  • 假设master和每个salve之间用一个TCP连接,那么master采用2个或4个IO线程来处理8个CPU connections能有效降低延迟;
  • master要异步的往磁盘上写log,这要求logging library有自己的IO线程;
  • master有可能读写数据库,那么数据库连接这个第三方library有可能有自己的线程,并回调master的代码;
  • master要服务于多个clients,用多线程也能降低客户响应时间,其可以再用2个IO线程专门处理和clients的通信;
  • master还可以提供一个monitor接口,用来广播推送机群的状态,这样用户不用主动轮询,这个功能如果用单独的线程来做,会比较容易实现,不会搞乱其他主要功能;

  • master一共开了10个线程:

    • 4个用于和slaves通信的IO线程;
    • 1个logging线程;
    • 一个数据库IO线程;
    • 2个和clients通信的IO线程;
    • 1个主线程,用于做些背景工作,如job调用等;
    • 1个pushing线程,用于主动广播机群的状态;
  • 虽然线程数目略多于core数目,但是这些线程很多时候都是空闲的,可以依赖OS的进程调度来保证可控的延迟;

线程的分类

一个多线程服务程序的线程大致可分为3类:

  • IO线程:这类线程的主循环是IO multiplexing,阻塞的等在select/poll/epoll_wait系统调用上,这类线程也处理定时事件,当然它的功能不止IO,有些简单的计算也可以放入其中;
  • 计算线程:这类线程的主循环是blocking queue,阻塞的等在condition variable上。这类线程一般位于thread pool中,通常不涉及IO,一般要避免任何阻塞操作;
  • 第三方库所用的线程,比如logging、database connection;

线程池大小的阻抗匹配原则

如果池中线程在执行任务时,密集计算所占的时间比重不过为P(0 < p ≤ 1),而系统一共有C个CPU,为了让C个CPU跑满又不过载,线程池大小的经验公式为T = C / P;考虑到P的值估计不是很准确,T的最佳值可以上下浮动50%。

当P < 0.2,这个公式就不适用了,T可以取一个固定值,比如5*C,C不一定是CPU总数,可以是“分配给这项任务的CPU数”;

多线程系统编程精要

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

  • 当前线程可能随时会被切换出去,或者说被强占了;
  • 多线程程序中事件发生顺序不再有全局统一的前后关系;

在没有适当同步的情况下,多个CPU上运行的多个线程中的事件发生先后顺序是无法确定的;

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

不必担心系统调用的线程安全性,因为系统调用对于用户态程序来说是原子的。但是要注意系统调用对于内核状态的改变可能影响其他线程。

编写一个线程安全程序的难点在于线程安全是不可组合的,尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了。

可参考内容

原创粉丝点击