美文网首页
程序员的自我修养 Liunx多线程

程序员的自我修养 Liunx多线程

作者: one_zheng | 来源:发表于2019-08-04 16:19 被阅读0次

    Liunx的多线程

      Windows 对进程和线程的实现如同教科书一般标准,Windows内核有明确的线程和进程的概念。在Windows API中,可以使用明确的API: CreateProcess和 CreateThread来创建 进程和线程,并且有一系列的API来操纵它们。但对于Liunx来说,线程并不是一个通用的概念。

     Liunx内核中不存在真正意义上的线程概念。Liunx将所有的执行实体(无论是线程还是进程)都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Liunx下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。在Liunx下,用以下方法可以创建一个新的任务,如表1-2所示:

    image.png

    fork函数尝试一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。

    pid_t pid;
    if (pid = fork())
    {
    .....
    }
    

     在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回0.
    fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write,COW)的内存空间(见图1-10)。所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提高内修改方单独使用,以免影响到其他的任务使用。

    image.png

    fork只能产生本任务的镜像,因此必须要使用exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像。因此在fork产生了一个新任务只会,新任务可以调用exec来执行新的可执行文件。forkexec通常用于产生新任务,而如果要产生新线程,则可以用cloneclone函数原型如下:

    int clone(int (*fn)(void *), void* child_stack,int flags, void* arg);
    

     使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件等。如此就可以在实际效果上产生一个线程。

    线程安全

      多线程程序处于一个多变的环境当中,可以访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。

    竞争与原子操作
      多个线程同手访问一个共享数据,可能造成很恶劣的后果,下面是一个著名的例子,假设有两个线程分别要执行如表1-3所示的C代码。

    image.png
     在许多体系结构上,++i的实现方法会如下:

    (1)读取i到某个寄存器X
    (2)X++
    (3)将X的内容存储回i
     由于线程1和线程2并发执行,因此两个线程的执行序列很可能如下(注意,寄存器X的内容在不同的线程中是不一样的,这里用X[1]和X[2]分别表示线程1和线程2中的X),如表1-4所示

    image.png
     和明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被编译为汇编代码后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。我们把单指令的操作称为原子的,因为无论如何,单条指令的执行是不会被打断的。为了避免出错,很多体系结构都提供了一些常用操作的原子指令。

    同步和锁
     为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。
     同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获得(Acquire)锁,并在访问之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。

    二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。

    多元信号量,允许多个线程并发访问资源。一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:

    • 将信号量的值减1.
    • 如果信号量的值小于0,则进入等待状态,否则继续执行。
      访问完资源之后,线程释放信号量,进行如下操作:
    • 将信号量的值加1
    • 如果信号量的值小于1,唤醒一个等待中的线程。

    互斥量(Mutex)与二元信号量类似,资源仅允许一个线程访问,但和信号量不同的时,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。

    临界区(Critical Section)是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的。也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。

    读写锁(Read-Write Lock)。对于同一个锁,读写锁有两种获取方式。共享的(Shared)独占的(Exclkusive)。当锁处于自由状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它就必须等待锁被所有的线程释放。相应的,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。

    image.png

    条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以用两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

    可重入(Reentrant)与线程安全

     一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
    (1)多个线程同时执行这个函数
     (2) 函数自身(可能是经过多层调用之后)调用本身。

     一个函数被称为可重入,表明函数被重入之后不会产生任何不良影响。一个函数要成为可重入的,必须具有如下几个特点:

    • 不使用任务(局部)静态或全局的非const变量。
    • 不返回任何(局部)静态或全局的非const变量的指针。
    • 仅依赖于调用方提供的参数。
    • 不依赖任何单个资源的锁(mutex等)。
    • 不调用任何不可重入函数。

    可重入是并发安全的强力保障,一个可重入函数可以在多线程环境下放心使用。

    多线程内部情况

    三种线程模型
     线程的并发执行时由多处理器或操作系统调度来实现的。但实际情况更会复杂一些:大多数操作系统,包括Windows和Liunx,都在内核里提供线程的支持,内核线程(注:这里的内核线程和Liunx内核里的kernel_thread并比不是一回事)由多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户线程并不一定在操作系统内核里对用同等数量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程在同时执行,对内核来说很可能只有一个线程。

    用户态多线程的三种实现方式:

    1.一对一模型
     对应直接支持线程的系统,一对一模型始终是最为简单的模型。对一对一模型来说,一个用户使用的线程就是唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在)

    image.png

     这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此外,一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。

     在Liunx系统里使用clone产生的线程就是一个一对一线程,为此时在内核中有一个唯一的线程与之对应。


    image.png

     一对一线程缺点有两个:

    • 由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制;
    • 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。

    2.多对一模型
     多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换要快速许多。

    image.png
     多对一模型的缺点:
    • 如果其中一个用户线程阻塞,所有线程都将无法执行,因为此时内核里的线程也随之阻塞了。
    • 多处理器系统上处理器的增多对多对一模型的线程性能也不会有明显的帮组。
       优点:高效的上下文切换和几乎无限制的线程数量。

    3.多对多模型
     多对多模型结合了多对一模型的一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上

    image.png
    优点:
    • 一个用户线程阻塞不会所所有线程阻塞,因为此时还有别的线程可以被调度来执行
    • 对用户线程的数量没什么限制
    • 在多处理器系统上,多对多模型的线程性能可以得到一定提升,不过提升幅度不如一对一模型高。

    相关文章

      网友评论

          本文标题:程序员的自我修养 Liunx多线程

          本文链接:https://www.haomeiwen.com/subject/yuqprctx.html