聊聊高并发(二)结合实例说说线程封闭和背后的设计思想

高并发问题抛去架构层面的问题,落实到代码层面就是多线程的问题。多线程的问题主要是线程安全的问题(其他还有活跃性问题,性能问题等)。

那什么是线程安全?下面这个定义来自《Java并发编程实战》,这本书强烈推荐,是几个Java语言的作者合写的,都是并发编程方面的大神。

线程安全指的是:当多个线程访问某个类时,这个类始终都能表现出正确的行为。

正确指的是“所见即所知”,程序执行的结果和你所预想的结果一致。

理解线程安全的概念很重要,所谓线程安全问题,就是处理对象状态的问题。如果要处理的对象是无状态的(不变性),或者可以避免多个线程共享的(线程封闭),那么我们可以放心,这个对象可能是线程安全的。当无法避免,必须要共享这个对象状态给多线程访问时,这时候才用到线程同步的一系列技术。

这个理解放大到架构层面,我们来设计业务层代码时,业务层最好做到无状态,这样就业务层就具备了可伸缩性,可以通过横向扩展平滑应对高并发。

所以我们处理线程安全可以有几个层次:

1. 能否做成无状态的不变对象。无状态是最安全的。

2. 能否线程封闭

3. 采用何种同步技术

我理解为能够“逃避”多线程问题,能逃则逃,实在不行了再来处理。

了解了线程封闭的背景,来说说线程封闭的具体技术和思路

1. 栈封闭

2. ThreadLocal

3. 程序控制线程封闭

栈封闭说白了就是多使用局部变量。理解Java运行时模型的同学都知道局部变量的引用是保持在线程栈中的,只对当前线程可见,其他线程不可见。所以局部变量是线程安全的。

ThreadLocal机制本质上是程序控制线程封闭,只不过是Java本身帮忙处理了。来看Java的Thread类和ThreadLocal类

1. Thread线程类维护了一个ThreadLocalMap的实例变量

2. ThreadLocalMap就是一个Map结构

3. ThreadLocal的set方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,把要放入的值作为value,放到Map

4. ThreadLocal的get方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,拿到对应的value.

public class Thread implements Runnable {
     ThreadLocal.ThreadLocalMap threadLocals = null;
}

public class ThreadLocal<T> {
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null)
                return (T)e.value;
        }
        return setInitialValue();
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
}

ThreadLocal的设计很简单,就是给线程对象设置了一个内部的Map,可以放置一些数据。JVM从底层保证了Thread对象之间不会看到对方的数据。

使用ThreadLocal前提是给每个ThreadLocal保存一个单独的对象,这个对象不能是在多个ThreadLocal共享的,否则这个对象也是线程不安全的。

Structs2就用了ThreadLocal来保存每个请求的数据,用了线程封闭的思想。但是ThreadLocal的缺点也显而易见,必须保存多个副本,采用空间换取效率。

程序控制线程封闭,这个不是一种具体的技术,而是一种设计思路,从设计上把处理一个对象状态的代码都放到一个线程中去,从而避免线程安全的问题

有很多这样的实例,Netty5的EventLoop就采用这样的设计,我们的游戏后台处理用户请求是也采用了这种设计。

具体的思路是这样的:

1. 把和用户状态相关的代码放到一个队列中去,由一个线程处理

2. 考虑是否隔离用户之间的状态,即一个用户使用一个队列,还是多个用户使用一个队列

拿Netty举例,EventLoop被设计成了一个线程的线程池。我们知道线程池的组成是工作线程 + 任务队列。EventLoop的工作线程只有一个。

用户请求过来后被随机放到一个EventLoop去,也就是放到EventLoop线程池的任务队列,由一个线程来处理。并且处理用户请求的代码都使用Pipeline职责链封装好了,一个Pipeline交给一个线程来处理,从而保证了跟同一个用户的状态被封闭到了一个线程中去。

更多Netty EventLoop相关的内容看这篇Netty5源码分析(二) -- 线程模型分析

这里有个问题也显而易见,就是如果把多个用户都放到一个队列,交给一个线程处理,那么前一个用户的处理速度会影响到后一个用户被处理的时间。

我们的游戏服务器的设计采用了一个用户一个任务队列的方式,处理任务的代码被做成了Runnable,这样多个Runnable可以交给一个线程池执行,从而多个用户可以同时被处理,而同一个用户的状态处理被封闭到了唯一的一个任务队列中,互不干扰

但是也有问题,即线程池内的工作线程和任务队列是有界的,所以单个线程处理的时间必须要快,否则大量请求被积压在任务队列来不及处理,一旦任务队列也满了,那么后续的请求都进不来了。

如果使用无界的任务队列,所有请求能进来,但是问题是高并发情况下大量请求过来,会把系统内存撑爆,倒置OOM。

所以一个常用的设计思路如下:

1. 采用有界的任务队列和不限个数的工作线程,这样可以平滑地处理高并发,不至于内存被撑爆

2. 单个线程请求时间必须要快,尽量不超过100ms

3. 如果单个线程处理的时间由于任务太大必须耗时,那么把任务拆个小任务来多次执行

4. 拆成小任务还是慢,那么把同步操作变成异步操作,即方法执行后立即返回,不要等待结果。由另一个线程异步地处理线程,比如采用单独的线程定时检查处理状态,或者采用异步回调的方式

时间: 2024-07-31 08:20:11

聊聊高并发(二)结合实例说说线程封闭和背后的设计思想的相关文章

聊聊高并发(二十五)解析java.util.concurrent各个组件(七) 理解Semaphore

前几篇分析了一下AQS的原理和实现.这篇拿Semaphore信号量做样例看看AQS实际是怎样使用的. Semaphore表示了一种能够同一时候有多个线程进入临界区的同步器,它维护了一个状态表示可用的票据,仅仅有拿到了票据的线程尽能够进入临界区,否则就等待.直到获得释放出的票据. Semaphore经常使用在资源池中来管理资源.当状态仅仅有1个0两个值时,它退化成了一个相互排斥的同步器.类似锁. 以下来看看Semaphore的代码. 它维护了一个内部类Sync来继承AQS,定制tryXXX方法来使

聊聊高并发(二十)解析java.util.concurrent各个组件(二) 12个原子变量相关类

这篇说说java.util.concurrent.atomic包里的类,总共12个.网上有非常多文章解析这几个类.这里挑些重点说说. 这12个类能够分为三组: 1. 普通类型的原子变量 2. 数组类型的原子变量 3. 域更新器 普通类型的原子变量的6个, 1. 当中AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference分别相应boolean, int,  long, object完毕主要的原子操作 2. AtomicMarkableRe

聊聊高并发(二十九)解析java.util.concurrent各个组件(十一) 再看看ReentrantReadWriteLock可重入读-写锁

上一篇聊聊高并发(二十八)解析java.util.concurrent各个组件(十) 理解ReentrantReadWriteLock可重入读-写锁 讲了可重入读写锁的基本情况和主要的方法,显示了如何实现的锁降级.但是下面几个问题没说清楚,这篇补充一下 1. 释放锁时的优先级问题,是让写锁先获得还是先让读锁先获得 2. 是否允许读线程插队 3. 是否允许写线程插队,因为读写锁一般用在大量读,少量写的情况,如果写线程没有优先级,那么可能造成写线程的饥饿 关于释放锁后是让写锁先获得还是让读锁先获得,

聊聊高并发(三十二)实现一个基于链表的无锁Set集合

Set表示一种没有反复元素的集合类,在JDK里面有HashSet的实现,底层是基于HashMap来实现的.这里实现一个简化版本号的Set,有下面约束: 1. 基于链表实现.链表节点依照对象的hashCode()顺序由小到大从Head到Tail排列. 2. 如果对象的hashCode()是唯一的.这个如果实际上是不成立的,这里为了简化实现做这个如果.实际情况是HashCode是基于对象地址进行的一次Hash操作.目的是把对象依据Hash散开.所以可能有多个对象地址相应到一个HashCode.也就是

聊聊高并发(二十七)解析java.util.concurrent各个组件(九) 理解ReentrantLock可重入锁

这篇讲讲ReentrantLock可重入锁,JUC里提供的可重入锁是基于AQS实现的阻塞式可重入锁.这篇 聊聊高并发(十六)实现一个简单的可重入锁 模拟了可重入锁的实现.可重入锁的特点是: 1. 是互斥锁,基于AQS的互斥模式实现,也就是说同时只有一个线程进入临界区,唤醒下一个线程时也只能释放一个等待线程 2. 可重入,通过设置了一个字段exclusiveOwnerThread来标示当前获得锁的线程.获取锁操作是,如果当前线程是已经获得锁的线程,那么获取操作成功.把当前状态作为获得锁次数的计数器

聊聊高并发(七)实现几种自旋锁(二)

在聊聊高并发(六)实现几种自旋锁(一) 这篇中实现了两种基本的自旋锁:TASLock和TTASLock,它们的问题是会进行频繁的CAS操作,引发大量的缓存一致性流量,导致锁的性能不好. 对TTASLock的一种改进是BackoffLock,它会在锁高争用的情况下对线程进行回退,减少竞争,减少缓存一致性流量.但是BackoffLock有三个主要的问题: 1. 还是有大量的缓存一致性流量,因为所有线程在同一个共享变量上旋转,每一次成功的获取锁都会产生缓存一致性流量 2. 因为回退的存在,不能及时获取

聊聊高并发(十二)分析java.util.concurrent.atomic.AtomicStampedReference源码来看如何解决CAS的ABA问题

在聊聊高并发(十一)实现几种自旋锁(五)中使用了java.util.concurrent.atomic.AtomicStampedReference原子变量指向工作队列的队尾,为何使用AtomicStampedReference原子变量而不是使用AtomicReference是因为这个实现中等待队列的同一个节点具备不同的状态,而同一个节点会多次进出工作队列,这就有可能出现出现ABA问题. 熟悉并发编程的同学应该知道CAS操作存在ABA问题.我们先看下CAS操作. CAS(Compare and

【转载】聊聊高并发系统之降级特技

原文:聊聊高并发系统之降级特技 在开发高并发系统时有三把利器用来保护系统:缓存.降级和限流.之前已经有一些文章介绍过缓存和限流了.本文将详细聊聊降级.当访问量剧增.服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务.系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级.本文将介绍一些笔者在实际工作中遇到的或见到过的一些降级方案供大家参考. 降级的最终目的是保证核心服务可用,即使是有损的.而且有些服务是无法降级的(如加入购

聊聊高并发(十三)实现几种自旋锁(六)

聊聊高并发(十一)实现几种自旋锁(五) 给出了限时有界队列锁的lock和unlock实现.这篇给出tryLock的实现 tryLock比lock略微复杂一点.要处理超时的情况.超时有几种情况: 1. 第一步在等待队列还没有获得节点的时候超时,直接返回false就可以 2. 第二步在等待队列已经获得节点可是还没有增加工作队列时超时,把节点状态能够直接改成FREE给兴许线程使用,然后返回false就可以 3. 第三步在前一个节点的状态上自旋时超时,将节点的preNode设置成前一个节点,然后将节点状