从多线程编程探讨高并发实现
多线程的介绍
线程的来源,为什么会有线程?
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。
线程的定义
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
操作系统中线程间任务调度方式
大部分操作系统(如Windows、Linux)的任务调度是采用时间片轮转的抢占式调度方式,也就是说一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被暂停的任务就处于就绪状态等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”。多任务运行示图如下:
多线程与多核
前面讲的线程调度是针对单个核来讲的调度情况,也就是同一时间点只能执行多个线程中的某一个线程。但是在多核的情况下,多线程又增加了不同的调度方式。多线程与内核的对应关系有三种:一对一、多对一、多对多模型。
一对一模型
对于一对一模型来说,一个线程就唯一地对应一个内核。线程之间的并发就是真正的并发执行,线程之间不会互相阻塞。最大的缺点就是线程的个数受到了内核数的限制。
多对一模型
多个线程绑定到一个核。解决了一对一模型的线程数的限制,但是如果线程里某一个线程阻塞,会影响到其他线程的执行,因为这些线程会互相争抢CPU资源。
多对多模型
多对多模型结合了一对一模型和多对一模型的优点,将多个用户线程映射到多个内核上。当前操作系统默认都采用的多对多模型,用于平衡多线程情况下性能和线程调度。当然,CPU密集型线程可以指定线程绑定到对应的核上,以提高线程执行的效率。
多线程带来了哪些编程复杂性?
共享数据访问(锁)
访问共享数据带来的线程安全问题,一直是多线程编程里面最让人头疼的问题。为避免这种情况发生,我们要将多个线程对同一数据的访问同步,确保线程安全。
互斥锁
所谓同步(synchronization)就是指一个线程访问数据时,其它线程不得对同一个数据进行访问,即同一时刻只能有一个线程访问该数据,当这一线程访问结束时其它线程才能对这它进行访问。同步最常见的方式就是使用锁(Lock),也称为线程锁。锁是一种非强制机制,每一个线程在访问数据或资源之前,首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁被占用时试图获取锁,线程会进入等待状态,直到锁被释放再次变为可用。
读写锁
读写锁(Read-Write Lock)允许多个线程同时对同一个数据进行读操作,而只允许一个线程进行写操作。这是因为读操作不会改变数据的内容,是安全的;而写操作会改变数据的内容,是不安全的。
信号量
多元信号量允许多个线程访问同一个资源,多元信号量简称信号量(Semaphore),对于允许多个线程并发访问的资源,这是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。
线程访问资源时首先获取信号量锁,进行如下操作:
1. 将信号量的值减1; 2. 如果信号量的值小于0,则进入等待状态,否则继续执行;
访问资源结束之后,线程释放信号量锁,进行如下操作:
1. 将信号量的值加1; 2. 如果信号量的值小于1(等于0),唤醒一个等待中的线程;
粗粒度的锁会降低效率,引起锁的竞争。过度细粒度的锁又会造成锁本身占用太多的运行时间和空间,同样会影响到效率。所以要解决锁在效率上的影响,应从程序设计方面寻找答案。
适应并行运行的程序结构设计
多线程场景下,如果不改变程序的设计,会很难最大程度的利用到多线程的效率优势。从设计上,尽量保证最小集的共享数据访问,最少的细粒度锁使用。
并行设计可以多考虑以下建议:
- 使用基于线程的局部变量存储线程中用到的数据(Thread Local)。
- 使用Volatile修饰变量。对同一个 Volatile 变量的写 / 读访问。
- 分治法(Divide-and-Conquer:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。)在多线程编程中,可以考虑将程序拆分为一个个较小的可执行任务(Task),并且这些任务之间相互独立,然后将任务分派到各个线程去执行。
为什么线程过多会带来性能问题?
线程的上下文交换的性能消耗相对于进程是很少的,但是在高性能场景中我们也不能忽视线程的上下文交换带来的性能损耗。主要表现在以下几个方面:
线程挂起和恢复,争抢资源
系统挂起线程时,保存当前线程的注册状态。恢复线程时,需要恢复线程的状态信息。这些动作都需要消耗CPU时钟周期,并且线程信息占用高速缓存。现代处理器都非常依赖高速缓存,它们的读取效率比内存要快100倍。但是高速缓存的容量有限,如果缓存区满,处理器会从高速缓存里面移出数据,腾出空间供新数据使用。所以在线程很多的情况下,线程会互相移除存储在高速缓存区的状态,争抢缓存区的高速缓存资源,这很明显会影响性能。
内存抖动
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存 (一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片, 还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。 与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易, 对真正的物理内存(例如RAM)的使用也更有效率。
抖动在分页存储管理系统中,内存中只存放了那些经常使用的页面, 而其它页面则存放在外存中,当进程运行需要的内容不在内存时, 便启动磁盘读操作将所需内容调入内存,若内存中没有空闲物理块, 还需要将内存中的某页面置换出去。也就是说,系统需要不断地在内外存之间交换信息。 若在系统运行过程中,刚被淘汰出内存的页面,过后不久又要访问它, 需要再次将其调入。而该页面调入内存后不久又再次被淘汰出内存,然后又要访问它。 如此反复,使得系统把大部分时间用在了页面的调入/换出上, 而几乎不能完成任何有效的工作,这种现象称为抖动。每一个线程都需要占用部分虚拟内存来存储它的堆栈和私有数据结构。跟高速缓存一样,大量的线程会导致内存页面不够用,需要频繁的在内存和磁盘中进行分页置换。极端情况下,如果线程足够多,会耗尽所有的虚拟内存。
持锁的线程挂起
线程挂起后,其它等待该锁的线程,必须等到持有锁的线程在恢复运行,释放锁之后才能运行。如果锁的获取顺序是先到先得,影响会更大。因为只要某一个等待线程挂起,那么所有在等待线程后面获取锁的所有线程都会被阻塞。很像中国的一句俗语叫“占着茅坑不拉屎”。
多线程与高并发有没有必然的关系?
一般来说,高并发的解决方案就是多线程模型,服务器为每个客户端请求分配一个线程,使用同步I/O,系统通过线程切换来弥补同步I/O调用的时间开销,比如Apache就是这种策略,由于I/O一般都是耗时操作,因此这种策略很难实现高性能,但非常简单,可以实现复杂的交互逻辑。
让我们回到线程最初的来源,多线程是提高并发效率的手段。但是也要记住,多线程只是手段,不是目的,我们的目的是要实现高并发,有效的利用处理器的计算资源。先介绍比较典型的几个高并发、高性能的开源组件。
如何高效的利用线程达到任务的高并发?
生活中遇到的很多场景,多是IO密集型。解决这类问题的核心思想就是减少cpu空转的时间,增加CPU的利用率。具体有下面两种方法:
限制活动线程的个数不超过硬件线程的个数
活动线程指Runnable状态的线程。
Blocked状态的线程个数不在限制内。Blocked状态的线程都在等待外部事件触发,比如鼠标点击、磁盘IO操作事件,操作系统会将他们移除到调度队列外,所以它们不会消耗cpu时间片。程序可以有很多的线程,但是只要保证活动的线程个数小于硬件线程的个数,运行效率是可以保证的。
计算密集型和IO密集型线程是要分开看待的。计算密集型线程应该永远不被block,大部分时间都要保证runnable状态。要有效的利用处理器资源,可以让计算密集型的线程个数跟处理器个数匹配。而IO密集型线程大部分时间都在等待IO事件,不需要太多的线程。
基于任务的编程(协程)
线程个数跟硬件线程一致。任务调度器把对应的任务放入跟线程做一个映射,放入到相应的线程执行。有几个明显的优势:
- 按需调度。
线程调度器的时间片是公平的分配给各个线程的,因为它不不理解程序的业务逻辑。这就跟计划经济样的,极度的公平就是不公平,市场经济这种按需分配才能提高效率。任务调度器是理解任务信息的,可以更有效的调度任务。 - 负载均衡。
将程序分成一个个小的任务,让调度器来调度,不让所有的线程空跑,保证线程随时有活干。有效的利用计算资源、平衡计算资源。 - 更易编程。
以线程为基础的编程,要提高效率,经常要考虑到底层的硬件线程,考虑线程调度受到的影响。但是如果基于任务来编程,只要集中注意力在任务之间的逻辑关系上,处理好任务之间的关系。调度效率可以交给调度器来管控。
单线程高并发的开源软件介绍
Nginx
Nginx 专为性能优化而开发,性能是其最重要的考量,实现上非常注重效率 。它支持内核 Poll 模型,能经受高负载的考验,有报告表明能支持高达 50,000 个并发连接数。
NGINX采用了异步、事件驱动的方法来处理连接。这种处理方式无需(像使用传统架构的服务器一样)为每个请求创建额外的专用进程或者线程,而是在一个工作进程中处理多个连接和请求。为此,NGINX工作在非阻塞的socket模式下,并使用了epoll 和 kqueue这样有效的方法。因为满负载进程的数量很少(通常每核CPU只有一个)而且恒定,每个进程里面也只有一个主线程,所以任务切换只消耗很少的内存,而且不会浪费CPU周期。通过NGINX本身的实例,这种方法的优点已经为众人所知。NGINX可以非常好地处理百万级规模的并发请求。
Node.js
Node.js采用 事件驱动 和 异步I/O 的方式,实现了一个单线程、高并发的运行时环境,而单线程就意味着同一时间只能做一件事,那么Node.js如何利用单线程来实现高并发?
多数网站的服务器端都不会做太多的计算,它们只是接收请求,交给其它服务(比如从数据库读取数据),然后等着结果返回再发给客户端。因此,Node.js针对这一事实采用了单线程模型来处理,它不会为每个接入请求分配一个线程,而是用一个主线程处理所有的请求,然后对I/O操作进行异步处理,避开了创建、销毁线程以及在线程间切换所需的开销和复杂性。
Node.js的高并发的核心机制就是线程中循环处理事件的机制。
事件循环处理方式
Node.js 在主线程中维护了一个事件队列。
- 当接收到请求后,就将请求作为一个事件放入该队列中,然后继续接收其他请求。
-
当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件。
- 如果是非I/O任务,就亲自处理,并通过回调函数返回到上层调用;
- 如果是I/O任务,就从线程池中拿出一个线程来执行这个事件,并指定回调函数,然后继续循环队列中的其他事件。当线程中的I/O任务完成后,就执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用。
这个过程就叫事件循环(Event Loop),如下图所示: