OS 多线程

1,672 阅读10分钟

最近在复习这块内容,随时整理一下,未完。

一、多线程

线程概念

  • 程序(program):为完成特定任务、用某种语言编写的一组指令。即指一段静态的代码
  • 进程(process):进程是动态的,是程序的一次执行过程,或是正在运行的一个程序。每一个进程执行都有一个执行顺序,该顺序是一个执行路径,或者叫一个控制单元;
  • 线程(thread):进程可进一步细化为线程,线程是进程中的一个执行单元,是一个程序内部的一条执行路径。一个进程中至少有一个线程。
  • 多线程:同一个进程内部有多个线程。
  1. 所有的线程共享同一个进程的内存空间(同样的动态内存、映射文件、目标代码……),进程中定义的全局变量会被所有的线程共享,比如有全局变量int i = 10,这一进程中所有并发运行的线程都可以读取和修改这个i的值。
  2. 除了标识线程的tid,每个线程还有自己独立的栈空间,线程彼此之间是无法访问其他线程栈上内容的。每个线程独自占用一个虚拟处理器
  3. 线程调度只需要保存线程栈、寄存器数据和指令指针(PC)即可,相比进程切换开销要小很多,线程是处理机调度的最小单位,是程序执行流的最小单元。
  4. 线程包括两个方面:第一:线程内核对象(OS用来存放统计信息的地方);第二:线程堆栈(函数参数和局部变量)
  5. 线程在它的进程的地址空间执行代码。内核对象句柄依赖于进程而存在。

什么时候使用多线程?

多线程模型主要优势为线程间切换代价较小,因此适合I/O密集型工作场景,所以在编写程序时,遇到了阻塞过程而不想使整个程序停止响应时,应使用多线程。同时,多线程模型也适用于单机多核分布式场景。

为啥使用多线程?

  • 提高cpu资源利用率(执行主线程任务时,还可以同时执行其他的)
  • 创建线程开销比较小(比创建进程要小)
  • 线程之间可以共享数据(进程之间不行)

线程基本状态

就绪、阻塞和运行三种基本状态。

  • 就绪状态,指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;
  • 运行状态,指线程占有处理机正在运行;
  • 阻塞状态,指线程在等待一个事件(如信号量),逻辑上不可执行。

并发并行

  • 一个逻辑流的执行在时间上与另一个流重叠,称为并发流,它们并发地执行的一般现象就叫做并发(concurrency),并发流的思想与流运行的处理器核数或者计算机数无关。
  • 如果两个流并发地运行在不同的处理器核或计算机上,就称它们为并行流(parallel flow)
  • 历史上最开始什么样,不代表固定不变。多进程有多进程的缺点,所以提出了多线程。并发、并行和多进程、多线程并没有严格的对应关系。
  • 并发更多的是针对逻辑结构,不同任务可以无需等待上一个任务完成才开始,多个任务可以同时开始。多个任务即可以以多进程方式开始,也可以以多线程方式开始。并行是多个任务可以同时在CPU上执行,并行更关注执行状态。

线程的创建和结束

  1. 创建线程:
int pthread_create( pthread_t *pthread, 
                    const pthread_attr_t *attr, 
                    void *(*start_routine)(void *),
                    void *agr
                  );
参数 说明
pthread_t *pthread, pthread:用来返回线程的tid,标识线程,*pthread值即为tid,类型pthread_t == unsigned long int。
const pthread_attr_t *attr, attr:指向线程属性结构体的指针,用于改变所创线程的属性,填NULL使用默认值。
void *(*start_routine)(void *), 定义了一个名字为start_routine的函数指针,指向返回类型是void* 类型(指针)且形参为void* 类型的函数。即:线程执行函数的首地址,传入函数指针。
void *arg); arg:通过地址传递来传递函数参数,这里是无符号类型指针,可以传任意类型变量的地址,在被传入函数中先强制类型转换成所需类型即可。
  1. 获得线程ID: pthread_t pthread_self(); 调用时,会打印线程ID。
  2. 等待线程结束: int pthread_join(pthread_t tid, void** retval);
    • 主线程调用:等待子线程退出并回收其资源,类似于进程中wait/ waitpid回收僵尸进程,调用pthread_join的线程会被阻塞
    • pthread_t tid,tid:创建线程时通过指针得到tid值。
    • void** retval,retval:指向返回值的指针。
  3. 结束线程: pthread_exit( void *retval );
    • 子线程执行,用来结束当前线程;并通过retval传递返回值,该返回值可通过pthread_join获得。
  4. 分离线程: int pthread_detach(pthread_t tid);
    • 主线程可以调用:pthread_detach(tid);
    • 子线程可以调用:pthread_detach(pthread_self());调用后和主线程分离,子线程结束时自己立即回收资源。

线程属性值修改

线程属性对象类型为pthread_attr_t,结构体定义如下:

typedef struct{
    int etachstate;    // 线程分离的状态
    int schedpolicy;    // 线程调度策略
    struct sched_param schedparam;    // 线程的调度参数
    int inheritsched;    // 线程的继承性
    int scope;    // 线程的作用域
    // 以下为线程栈的设置
    size_t guardsize;    // 线程栈末尾警戒缓冲大小
    int stackaddr_set;    // 线程的栈设置
    void *    stackaddr;    // 线程栈的位置
    size_t stacksize;    // 线程栈大小
}pthread_arrt_t;

对上述结构体中各参数大多有:pthread_attr_get***()和pthread_attr_set***()系统调用函数来设置和获取。这里不一一罗列。

多线程的同步互斥

在多任务操作系统中,同时运行的多个任务可能:

  • 都需要访问/使用同一种资源;
  • 多个任务之间有依赖关系,某个任务的运行依赖于另一个任务。
  • 同步:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。
    • 比如:A 任务的运行依赖于 B 任务产生的数据。
  • 互斥:当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。
    • 一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。

用户模式:不需要切换内核态,只在用户态完成操作。

临界区CriticalSection:

互斥only、线程所有权:串行到谁谁可、不可跨进程

  • 适合一个进程内的多线程访问公共区域或代码段时使用。 通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问,如果有多个线程试图访问公共资源,那么在有一个线程进入后,其他试图访问公共资源的线程将被挂起,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。

内核模式:利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态。

互斥量(互斥锁)Mutex:

  • 综述:原子操作。采用对象互斥锁的概念,保证数据同一时间唯一访问。互斥量有两个状态,解锁、加锁。(线程所有权:拥有互斥对象的可)可跨进程。

  • 过程:

    • 线程在访问共享资源后、临界区域前,对互斥锁进行加锁。线程访问完成后释放该锁。
    • 其他企图加锁的其他线程,将会被挂起。直到该锁被释放,被挂起的线程被唤醒并继续执行、锁定该互斥量。
  • 实现:

    • 创建和销毁

    (1)静态方式创建:pthread_mutex_t:结构,右值:结构常量

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    

    (2)动态方式创建:mutexattr用于指定互斥锁属性,如果为NULL则使用缺省属性。

    int pthread_mutex_init( pthread_mutex_t *mutex, 
                            const pthread_mutexattr_t *mutexattr ) 
    

    (3)注销互斥锁:释放锁所占用的资源,且要求锁当前处于开放状态。

    int pthread_mutex_destroy( pthread_mutex_t *mutex ) 
    

    注:在Linux中,互斥锁并不占用任何资源,因此LinuxThreads中的 pthread_mutex_destroy()除了检查锁状态以外(锁定状态则返回EBUSY)没有其他动作。

    • 互斥锁属性: 互斥锁的属性在创建锁的时候指定。
    • 锁的操作
    int pthread_mutex_lock(pthread_mutex_t *mutex)    // 加锁(在锁已经被占据时挂起)
    int pthread_mutex_unlock(pthread_mutex_t *mutex)  // 解锁
    int pthread_mutex_trylock(pthread_mutex_t *mutex) // 测试加锁(在锁已经被占据时返回EBUSY)
    

自旋锁

  • 和互斥量阻塞了线程不同,在获得锁之前一直处于忙等(自旋)阻塞状态。
  • 场景:自旋锁只能被短时间持有。

读写锁

  • 顾名思义,将对共享资源的访问者分为读者、写者。读写锁有三种状态:读模式下加锁,写模式下加锁,不加锁装填。
  • 一个读写锁同时只能有一个写者or多个读者(与CPU数相关),但不能同时既有读者又有写者。
  • 如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁,否则它必须自旋在那里,直到没有任何写者或读者。
  • 如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁。
  • 场景:读写锁适用于对数据结构的读次数远大于写次数的情况。
    pthread_rwlock_t myrw;
    pthread_rwlock_init(&myrw, NULL);
    pthread_rwlock_rdlock(&myrw);//只读模式去加锁 
    pthread_rwlock_wrlock(&myrw);//只写模式去加锁    
    pthread_rwlock_unlock(&myrw); 
    pthread_rwlock_destroy(&myrw);
    

屏障

  • 让多个线程并行工作,然后所有参与的线程都到达一个屏障后,从该点继续执行。比如:主线程中设一个屏障,它一直等待:6个工作线程并行处理数据,当6组都处理完成后,主线程对这些所有的数据继续操作。
  • 场景:适用于并发完成同一项任务。

信号量Semaphore:(同步or互斥、可跨进程)

  • 它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。
  • 与临界区和互斥量不同,可以实现多个线程同时访问公共区域数据。先设置一个访问公共区域的线程最大连接数,每有一个线程访问共享区资源数就减一,直到资源数小于等于零。

事件(信号)Event:(同步or互斥、可跨进程)

  • 通过线程间触发事件实现同步互斥。
  • 通过通知操作的方式来保持线程的同步,还可以方便实现对多个线程的优先级比较的操作。

Linux下的线程同步:互斥锁,自旋锁,读写锁,屏障。 Windows下的线程同步:互斥量,信号量,事件,关键代码段

参考资料: 操作系统技能树互斥锁、条件变量、读写锁、自旋锁、信号量多线程全面总结互斥量,读写锁,自旋锁,条件变量,屏障

二、多进程