🎁 迎长周末,VIP通行证6个月限时优惠$50 off 🎁 点击查看详情
查看: 2343|回复: 4
收起左侧

[其他] 多线程面试题

    |只看干货
本楼: 👍   92% (12)
 
 
7% (1)   👎
全局: 👍   89% (2243)
 
 
10% (274)    👎

注册一亩三分地论坛,查看更多干货!

您需要 登录 才可以下载或查看附件。没有帐号?注册账号

x
本帖最后由 Chasedream.df 于 2022-5-7 12:40 编辑

多线程面试题

经过60多家的面试洗礼,有些公司还是会考察多线程,比如dropbox,nuro,google,confluent,比如设计一个线程安全的delayscheduler,线程安全的读写hashmap
附陈硕写的c++多线程资料

临界资源

临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有,打印机,磁带机等;软件有消息队列,变量,数组,缓冲区等。诸进程间采取互斥方式,实现对这种资源的共享。

临界区:

每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。

线程和进程的区别?⭐
进程是系统资源分配的最小单位,线程是程序执行的最小单位
进程使用独立的数据空间,而线程共享线程的数据空间
进程的切换效率比线程低
通信方式不同
进程间通信方式
无名管道:半双工的,即数据只能在一个方向上流动,只能用于具有亲缘关系的进程之间的通信,可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
FIFO命名管道:FIFO是一种文件类型,可以在无关的进程之间交换数据,与无名管道不同,FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
消息队列:消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
信号量:信号量是一个计数器,信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
共享内存:共享内存指两个或多个进程共享一个给定的存储区,一般配合信号量使用。
TODO:简单介绍进程的切换过程

主要考察线程上下文的切换代价,要回答切换会保持寄存器、栈等线程相关的现场,需要由用户态切换到内核态,最后知道可以通过vmstate命令查看上下文的切换状况

线程通信方式
volatile 关键字: 使用共享内存的思想,多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
使用 Object 类的wait() 和 notify() 方法
使用 ReentrantLock 结合 Condition的 await() 和 signal() 方法
信号量 Semaphore,可以控制对共享资源的并发访问度,有 accquire() 和 release() 方法
CountDownLatch:控制线程等待,计数器功能,可以用来等待多个线程执行任务后进行汇总
CyclicBarrier:类似CountDownLatch但更强大,可以重复使用,控制多个线程,一般测试使用
基本LockSupport实现线程间的阻塞和唤醒
死锁的4个必要条件⭐
互斥条件:一个资源每次只能被一个线程使用;
请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺;
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何分析是否有线程死锁? ⭐
使用jconsole图形化工具直接检查死锁

使用jstack命令行分析线程Dump信息

理解线程的同步与异步、阻塞与非阻塞
同步与异步的区别是任务是否在同一个线程中执行的 ,阻塞与非阻塞的区别是异步执行任务时线程是不是会阻塞等待结果还是会继续等待后面的逻辑

创建线程有哪几种方式,如何实现?
①. 继承Thread类创建线程类

定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
创建Thread子类的实例,即创建了线程对象。
调用线程对象的start()方法来启动该线程。
②. 通过Runnable接口创建线程类

定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
调用线程对象的start()方法来启动该线程。
③. 通过Callable和Future创建线程

创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
使用FutureTask对象作为Thread对象的target创建并启动新线程。
调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
④. 通过线程池创建线程

调用Executors.newFixedThreadPool方法创建线程池。
Runnable的匿名内部类创建线程。
结束要调用shutdown关闭线程池。
runnable 和 callable 有什么区别
Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;
Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
sleep和wait的区别⭐
本质区别 Thread.sleep只会让出CPU ,不会导致锁行为的改变 Object.wait不仅让出CPU , 还会释放已经占有的同步资源锁

wait属于Object类,sleep属于Thread类
wait会释放对象锁,而sleep不会
wait需要在同步块中使用,sleep可以在任何地方使用
sleep需要捕获异常、wait不需要
notify()和 notifyAll()有什么区别
锁池EntiyList:当一个线程需要调用调用此方法时必须获得该对象的锁,而该对象的锁被其他线程占用,该线程就需要在一个地方等待锁释放,这个地方就是锁池。(准备抢锁的池子) 等待池WaitSet:调用了wait方法的线程会释放锁并进入等待池,在等待池的线程不会竞争锁。(休息的池子)

notifyAll会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会

notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会。

线程的 run()和 start()有什么区别?/为什么不能直接调用 run() 方法?
每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。

start()方法来启动一个线程,这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码,真正实现了多线程运行。 此时start()方法启动的线程是处于就绪状态, 但并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, run方法运行结束, 此线程终止。然后CPU再调度其它线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接调用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

线程的生命周期和状态
新建(New) :创建后尚未启动的线程的状态

可运行(Runnable):就绪和运行两种状态统称为运行中

阻塞(Blocked):等待获取排它锁

无限期等待(Waiting): 阻塞和等待的区别在于,阻塞是被动的,而等待是主动的,不会被分配CPU执行时间,需要显式被唤醒

进入方法        退出方法
没有设置 Timeout 参数的 Object.wait() 方法        Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法        被调用的线程执行完毕
LockSupport.park() 方法        LockSupport.unpark(Thread)
限期等待(Timed Waiting):在一定时间后会由系统自动唤醒
进入方法        退出方法
Thread.sleep() 方法        时间结束
设置了 Timeout 参数的 Object.wait() 方法        时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法        时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法        LockSupport.unpark(Thread)
LockSupport.parkUntil() 方法        LockSupport.unpark(Thread)
调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。

结束(Terminated):已终止线程的状态,线程已经结束执行
线程的各种状态的切换(重要)⭐


得到一个线程类,new出一个实例线程就进入new状态(新建状态)。
调用start方法就进入Runnable(可运行状态)
如果此状态被操作系统选中并获得时间片就进入Running状态
如果Running状态的线程的时间片用完或者调用yield方法就可能回到Runnable状态
处于Running状态的线程如果在进入同步代码块/方法就会进入Blocked状态(阻塞状态),锁被其它线程占有,这个时候被操作系统挂起。得到锁后会回到Running状态。
处于Running状态的线程如果调用了wait/join/LockSupport.park()就会进入等待池(无限期等待状态), 如果没有被唤醒或等待的线程没有结束,那么将一直等待。
处于Running状态的线程如果调用了sleep(睡眠时间)/wait(等待时间)/join(等待时间)/ LockSupport.parkNanos(等待时间)/LockSupport.parkUntil(等待时间)方法之后进入限时等待状态,等待时间结束后自动回到原来的状态。
处于Running状态的线程方法执行完毕或者异常退出就会进入死亡状态。
有哪几种实现生产者消费者模式的方法?
锁、信号量、线程通信、阻塞队列。

什么是上下文切换?⭐
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式(程序计数器)。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

创建线程池ThreadPoolExecutor有哪几种方式?
①. newFixedThreadPool(int nThreads) 创建一个固定线程数量的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,新的任务会暂存在任务队列中,待有线程空闲时便处理任务。

②. newCachedThreadPool() 创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。

③. newSingleThreadExecutor() 这是一个单线程的Executor,它的特点是能确保依照任务在队列中的顺序来串行执行,适用于保证异步执行顺序的场景。

④. newScheduledThreadPool(int corePoolSize)(推荐) 创建了一个固定长度的线程池,以定时的方式来执行任务,适用于定期执行任务的场景。

⑤.newWorkStealingPool 使用ForkJoinPool ,多任务队列的固定并行度,适合任务执行时长不均匀的场景

前四个都是使用 ThreadPoolExecutor() 的不同初始化参数创建的。

场景:大量短期的任务场景适合使用 Cached 线程池,系统资源比较紧张时使用固定线程池。慎用无界队列,有OOM风险。自己项目有一个可能高吞吐量的场景就使用了 Cached 线程池

线程池都有哪些状态
RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务 SHUTDOWN :不再接受新提交的任务,但可以处理存量任务 STOP :不再接受新提交的任务,也不处理存量任务 TIDYING :所有的任务都已终止 TERMINATED : 结束方法terminated()执行完后进入该状态

线程池核心参数⭐
corePoolSize:线程池里的线程数量,核心线程池大小
maxPoolSize:线程池里的最大线程数量
workQueue: 任务队列,用于存放提交但是尚未被执行的任务。
keepAliveTime:当线程池中的线程数量大于 corePoolSize 时,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁; 参数的时间单位为 unit。
阻塞队列种类
threadFactory:线程工厂,用于创建线程,一般可以用默认的
handler:拒绝策略
线程池的拒绝策略⭐
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy:由提交任务的线程直接处理该任务
如何向线程池提交任务
有2种:分别使用execute 方法和 submit 方法

执行execute()方法和submit()方法的区别是什么呢?
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以了解任务执行情况,并且可以通过 Future 的 get() 方法来获取返回值,还可以取消任务执行。底层也是通过 execute() 执行的。
线程池常用的阻塞队列?
ArrayBlockingQueue:基于数组实现的一个单端阻塞队列,只能从队尾出队。
LinkedBlockingQueue:基于链表实现的一个双端阻塞队列,分别从队头队尾操作入队出队。
PriorityBlockingQueue:以上2种队列都是先进先出队列,而PriorityBlockingQueue却不是,它会按照元素的优先级对元素进行排序,按照优先级顺序出队,每次出队的元素都是优先级最高的元素。注意,此阻塞队列为无界阻塞队列,即容量没有上限(通过源码就可以知道,它没有容器满的信号标志),前面2种都是有界队列。
DelayQueue:基于PriorityQueue,一种延时阻塞队列,DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue也是一个无界队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
怎么保证多线程的运行安全/保证线程安全的方法⭐
前提是保证下面三个方面:

原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);

可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);

有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。

可以使用 CAS、Synchronized、Lock、ThreadLocal 来实现。

如何尽可能提高多线程并发性能?
尽量减少临界区范围、使用ThreadLocal、减少线程切换、使用读写锁或CopyOnWrite机制

为什么要使用线程池⭐
减少创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
可以根据系统的承受能力,调整线程池中工作线程的数目,放置因为消耗过多的内存,而把服务器累趴下
线程池满了,往线程池里提交任务会发生什么样的情况,具体分几种情况
如果你使用的LinkedBlockingQueue(阻塞队列),也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy。
线程池的饱和策略:当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略
向线程池提交一个线程的原理/步骤⭐
也叫做ThreadPoolexecutor工作流程

先判断核心线程池是否已满
如果没有满就创建线程
如果满了就判断等待队列是否已满
如果没满就加入等待队列
如果满了就判断最大线程池是否已满
没有满就提交给线程池
满了就执行拒绝策略
如何指定多个线程的执行顺序/ 如何控制线程池线程的优先级 ⭐
设定一个 orderNum,每个线程执行结束之后,更新 orderNum,指明下一个要执行的线程。并且唤醒所有的等待线程。
在每一个线程的开始,要 while 判断 orderNum 是否等于自己的要求值,不是,则 wait,是则执行本线程。
线程池的线程数量怎么确定
一般来说,如果是CPU密集型应用,则线程池大小设置为N+1。
一般来说,如果是IO密集型应用,则线程池大小设置为2N+1。
在IO优化中,线程等待时间所占比例越高,需要越多线程,线程CPU时间所占比例越高,需要越少线程。这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
常用的线程分析工具与方法
jstack 分析线程的运行状态,查看锁对象的持有状况。

jconsole:JDK自带的图形化界面工具

volatile作用⭐
在多线程开发中保证了共享变量的“ 可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

volatile 告诉编译器它修饰的变量会不断的被修改,编译器就会通过强制主内存读写同步,防止指令重排序来保证原子性,可见性和有序性。但不能代替锁,不能保证i++这种复合操作的原子性。

Java 中是如何实现线程同步的?
1.同步方法 synchronized 关键字修饰的方法(悲观锁)

2.使用特殊域变量(volatile)实现线程同步(保持可见性,多线程更新某一个值时,比如说线程安全单例双检查锁)

3.ThreadLocal(每个线程获取的都是该变量的副本)

4.使用重入锁实现线程同步(相对 synchronized 锁粒度更细了,效率高)

​ 一个java.util.concurrent 包来支持同步。

​ ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁

​ ReentrantLock() : 创建一个 ReentrantLock 实例

​ lock() : 获得锁

​ unlock() : 释放锁

5.java.util.concurrent.atomic 包 (乐观锁)

​ 方便程序员在多线程环境下,无锁的进行原子操作



i++是线程安全的吗?
分2种情况

局部变量肯定是线程安全的(原因:方法内局部变量是线程私有的)
成员变量多个线程共享时,就不是线程安全的(原因:成员变量是线程共享的,因为 i++ 是三步操作。
介绍 Synchronized
保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

三种使用方式:

synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。

Synchronized 的实现原理
对象在内存中分为对象头,实例数据和对齐填充三个区域。在对象头中保存了锁标志位和指向 Monitor 对象的起始地址。当 Monitor 被某个线程占用后就会处于锁定状态。 synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 synchronized 修饰的方法使用是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

JDK1.6 之后的synchronized 关键字底层做了一些优化
偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化,详见 JVM 篇。

Synchronized与Lock的区别⭐
synchronized是java内置关键字在jvm层面,Lock是个java类。
synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁,并且可以主动尝试去获取锁。
synchronized会自动释放锁,Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁。
ReentrantLock更加灵活,提供了超时获取锁,可中断锁,在获取不到锁的情况会自己结束,而synchronized不可以
synchronized的锁不可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可)
乐观锁和悲观锁的区别?
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现

乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量

如何实现一个乐观锁?
在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

ReentrantLock如何实现公平和非公平锁
公平锁需要系统维护一个有序队列,获取锁时会判断阻塞队列里是否有线程再等待,若有获取锁就会失败,并且会加入阻塞队列。

非公平锁获取锁时不会判断阻塞队列是否有线程再等待,所以对于已经在等待的线程来说是不公平的,但如果是因为其它原因没有竞争到锁,它也会加入阻塞队列。

进入阻塞队列的线程,竞争锁时都是公平的,因为队列为先进先出(FIFO)。

JUC工具类
Atomic类
所谓原子类说简单点就是具有原子/原子操作特征的类。

基本数据类型的原子类
AtomicLong/AtomicInteger/AtomicBoolean:通过底层工具类 unsafe 类实现,基于 CAS。unsafe 类提供了类似 C 的指针操作,都是本地方法。
LongAdder/LongAccumulator:基于 Cell 实现,基于分段锁思想,是一种以空间换时间的策略,适合高并发场景。
AtomicReference:引用类型原子类,用于原子性对象的读写。
AtomicStampedReference/AtomicMarkableReference:解决 ABA 问题的类
Atomic类如何保证原子性⭐
​ CAS,Compare and Swap即比较并交换。主要利用 CAS (compare and swap) + volatile 和 unsafe 类的 底层 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 可能会导致什么问题?
ABA问题,就是在写入时读取到的数据是和预期的一样的 A,但是这个 A 可能已经被其他线程修改成 B 再修改回来了。解决办法:增加额外的标志位或时间戳。

AQS
AQS的全称为(AbstractQueuedSynchronizer),在java.util.concurrent.locks包下面。

AQS定义两种资源共享方式

Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

异步工具类:

Executors CompletableFuture:支持流式调用,多future组合,可以设置完成时间 FutureTask ForkJoinPool :分治思想+工作窃取

ThreadLocal 实现原理
每个线程独享的局部变量,ThreadLocal使用弱引用 ThreadLocalMap 保存弱引用的局部变量。使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。

ThreadLocal用来解决什么问题?
ThreadLocak不是用来解决多线程共享变量的问题,而是线程数据隔离的问题

参考资料
https://github.com/qiurunze123/t ... cs/thread-base-1.md
https://github.com/BrightStarry/ ... aster/summarize.txt
https://blog.csdn.net/solstice/

multithreaded_server.pdf

373.9 KB, 阅读权限: 1, 下载次数: 39, 下载积分: 大米 -1 颗

评分

参与人数 25大米 +51 收起 理由
astrob3rry + 1 赞一个
yezalan + 1 赞一个
panda + 1 给你点个赞!
xp2309 + 1 给你点个赞!
BrandoZ + 2 很有用的信息!
frizfealer + 1 很有用的信息!
Myron2017 + 2 给你点个赞!
qmonster + 1 赞一个

查看全部评分


上一篇:70道C++基础知识题,适合cs补课的同学
下一篇:面试必知的 Spark SQL 几种 Join 实现

本帖被以下淘专辑推荐:

 楼主| Chasedream.df 2022-5-8 12:48:23 | 显示全部楼层
本楼: 👍   0% (0)
 
 
0% (0)   👎
全局: 👍   89% (2243)
 
 
10% (274)    👎
单台服务器并发TCP连接数到底可以有多大

从硬件和操作系统支持来看,“单台服务器支持上万并发连接”已经没有多少挑战性了。

我们先假设:单台服务器最多只能支持万级并发连接。其实,这对绝大多数应用来说,已经远远足够了。但是,对于一些拥有很大用户基数的互联网公司,往往面临的并发连接数是百万、千万,甚至是上亿(百度、阿里、腾讯等公司,其网络服务TCP并发连接数往往会过亿)。

现在的集群、分布式技术,可以将并发负载分担到多台服务器上。所以,只需要扩展出数十台服务器,就可以解决问题。但是,我们更希望:更大程度地挖掘单台服务器的资源,先努力垂直扩展,再进行水平扩展。这样可以有效节省服务器相关的开支
————————————————
版权声明:本文为CSDN博主「爱思考的实践者」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/chinawangfei/article/details/102777684
回复

使用道具 举报

 楼主| Chasedream.df 2022-5-9 05:23:53 | 显示全部楼层
本楼: 👍   0% (0)
 
 
0% (0)   👎
全局: 👍   89% (2243)
 
 
10% (274)    👎


1. Linux 能同时启动多少个线程?
对于 32-bit Linux,一个进程的地址空间是 4G,其中用户态能访问 3G 左右,而一个线程的默认栈 (stack) 大小是 10M,心算可知,一个进程大约最多能同时启动 300 个线程。如果不改线程的调用栈大小的话,300 左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库、等等)同样要占用内存(地址空间)。

对于 64-bit 系统,线程数目可大大增加,具体数字我没有测试,因为我实际用不到那么多线程。

以下的关于线程数目的讨论以 32-bit Linux 为例。



2. 多线程能提高并发度吗?
如果指的是“并发连接数”,不能。

由问题 1 可知,假如单纯采用 thread per connection 的模型,那么并发连接数最多 300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千上万,甚至几万)。所谓“基于事件”,指的是用 IO multiplexing event loop 的编程模型,又称 Reactor 模式,在《常用模型》一文中已有介绍。

那么采用《常用模型》一文中推荐的 event loop per thread 呢?至少不逊于单线程程序。

小结:thread per connection 不适合高并发场合,其 scalability 不佳。event loop per thread 的并发度不比单线程程序差。



3. 多线程能提高吞吐量吗?
对于计算密集型服务,不能。

假设有一个耗时的计算服务,用单线程算需要 0.8s。在一台 8 核的机器上,我们可以启动 8 个线程一起对外服务(如果内存够用,启动 8 个进程也一样)。这样完成单个计算仍然要 0.8s,但是由于这些进程的计算可以同时进行,理想情况下吞吐量可以从单线程的 1.25cps (calc per second) 上升到 10cps。(实际情况可能要打个八折——如果不是打对折的话。)

假如改用并行算法,用 8 个核一起算,理论上如果完全并行,加速比高达 8,那么计算时间是 0.1s,吞吐量还是 10cps,但是首次请求的响应时间却降低了很多。实际上根据 Amdahl's law,即便算法的并行度高达 95%,8 核的加速比也只有 6,计算时间为 0.133s,这样会造成吞吐量下降为 7.5cps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的。

这也回答了问题 4。



如果用 thread per request 的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界值 T’ 时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加(分析与数据请见《A Design Framework for Highly Concurrent Systems》 by Matt Welsh et al.)。thread per request 是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程,比如 Java Servlet 中,一次页面请求由一个函数 HttpServlet#service(HttpServletRequest req, HttpServletResponse resp) 同步地完成。

为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池,线程池的大小应该满足“阻抗匹配原则”,见问题 7。

线程池也不是万能的,如果响应一次请求需要做比较多的计算(比如计算的时间占整个 response time 的 1/5 强),那么用线程池是合理的,能简化编程。如果一次请求响应中,thread 主要是在等待 IO,那么为了进一步提高吞吐,往往要用其它编程模型,比如 Proactor,见问题 8。



4. 多线程能降低响应时间吗?
如果设计合理,充分利用多核资源的话,可以。在突发 (burst) 请求时效果尤为明显。

例1: 多线程处理输入。

以 memcached 服务端为例。memcached 一次请求响应大概可以分为 3 步:

读取并解析客户端输入
操作 hashtable
返回客户端
在单线程模式下,这 3 步是串行执行的。在启用多线程模式时,它会启用多个输入线程(默认是 4 个),并在建立连接时按 round-robin 法把新连接分派给其中一个输入线程,这正好是我说的 event loop per thread 模型。这样一来,第 1 步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第 2 步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方。

比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个 IO 线程上,那么两个请求的第 1 步操作可以在两个线程上并行执行,然后汇总到第 2 步串行执行,这样总的响应时间比完全串行执行要短一些(在“读取并解析”所占的比重较大的时候,效果更为明显)。请继续看下面这个例子。



例2: 多线程分担负载。

假设我们要做一个求解 Sudoku 的服务(见《谈谈数独》),这个服务程序在 9981 端口接受请求,输入为一行 81 个数字(待填数字用 0 表示),输出为填好之后的 81 个数字 (1 ~ 9),如果无解,输出 “NO/r/n”。

由于输入格式很简单,用单个线程做 IO 就行了。先假设每次求解的计算用时 10ms,用前面的方法计算,单线程程序能达到的吞吐量上限为 100req/s,在 8 核机器上,如果用线程池来做计算,能达到的吞吐量上限为 800req/s。下面我们看看多线程如何降低响应时间。

假设 1 个用户在极短的时间内发出了 10 个请求,如果用单线程“来一个处理一个”的模型,这些 reqs 会排在队列里依次处理(这个队列是操作系统的 TCP 缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟的情况下,第 1 个请求的响应时间是 10ms;第 2 个请求要等第 1 个算完了才能获得 CPU 资源,它等了 10ms,算了 10ms,响应时间是 20ms;依次类推,第 10 个请求的响应时间为 100ms;10个请求的平均响应时间为 55ms。

如果 Sudoku 服务在每个请求到达时开始计时,会发现每个请求都是 10ms 响应时间,而从用户的观点,10 个请求的平均响应时间为 55ms,请读者想想为什么会有这个差异。

下面改用多线程:1 个 IO 线程,8 个计算线程(线程池)。二者之间用 BlockingQueue 沟通。同样是 10 个并发请求,第 1 个请求被分配到计算线程1,第 2 个请求被分配到计算线程 2,以此类推,直到第 8 个请求被第 8 个计算线程承担。第 9 和第 10 号请求会等在 BlockingQueue 里,直到有计算线程回到空闲状态才能被处理。(请注意,这里的分配实际上是由操作系统来做,操作系统会从处于 Waiting 状态的线程里挑一个,不一定是 round-robin 的。)

这样一来,前 8 个请求的响应时间差不多都是 10ms,后 2 个请求属于第二批,其响应时间大约会是 20ms,总的平均响应时间是 12ms。可以看出比单线程快了不少。

由于每道 Sudoku 题目的难度不一,对于简单的题目,可能 1ms 就能算出来,复杂的题目最多用 10ms。那么线程池方案的优势就更明显,它能有效地降低“简单任务被复杂任务压住”的出现概率。



以上举的都是计算密集的例子,即线程在响应一次请求时不会等待 IO,下面谈谈更复杂的情况。



5. 多线程程序如何让 IO 和“计算”相互重叠,降低 latency?
基本思路是,把 IO 操作(通常是写操作)通过 BlockingQueue 交给别的线程去做,自己不必等待。

例1: logging

在多线程服务器程序中,日志 (logging) 至关重要,本例仅考虑写 log file 的情况,不考虑 log server。

在一次请求响应中,可能要写多条日志消息,而如果用同步的方式写文件(fprintf 或 fwrite),多半会降低性能,因为:

文件操作一般比较慢,服务线程会等在 IO 上,让 CPU 闲置,增加响应时间。
就算有 buffer,还是不灵。多个线程一起写,为了不至于把 buffer 写错乱,往往要加锁。这会让服务线程互相等待,降低并发度。(同时用多个 log 文件不是办法,除非你有多个磁盘,且保证 log files 分散在不同的磁盘上,否则还是受到磁盘 IO 瓶颈制约。)
解决办法是单独用一个 logging 线程,负责写磁盘文件,通过一个或多个 BlockingQueue 对外提供接口。别的线程要写日志的时候,先把消息(字符串)准备好,然后往 queue 里一塞就行,基本不用等待。这样服务线程的计算就和 logging 线程的磁盘 IO 相互重叠,降低了服务线程的响应时间。

尽管 logging 很重要,但它不是程序的主要逻辑,因此对程序的结构影响越小越好,最好能简单到如同一条 printf 语句,且不用担心其他性能开销,而一个好的多线程异步 logging 库能帮我们做到这一点。(Apache 的 log4cxx 和 log4j 都支持 AsyncAppender 这种异步 logging 方式。)



例2: memcached 客户端

假设我们用 memcached 来保存用户最后发帖的时间,那么每次响应用户发帖的请求时,程序里要去设置一下 memcached 里的值。这一步如果用同步 IO,会增加延迟。

对于“设置一个值”这样的 write-only idempotent 操作,我们其实不用等 memcached 返回操作结果,这里也不用在乎 set 操作失败,那么可以借助多线程来降低响应延迟。比方说我们可以写一个多线程版的 memcached 的客户端,对于 set 操作,调用方只要把 key 和 value 准备好,调用一下 asyncSet() 函数,把数据往 BlockingQueue 上一放就能立即返回,延迟很小。剩下的时就留给 memcached 客户端的线程去操心,而服务线程不受阻碍。

其实所有的网络写操作都可以这么异步地做,不过这也有一个缺点,那就是每次 asyncWrite 都要在线程间传递数据,其实如果 TCP 缓冲区是空的,我们可以在本线程写完,不用劳烦专门的 IO 线程。Jboss 的 Netty 就使用了这个办法来进一步降低延迟。



以上都仅讨论了“打一枪就跑”的情况,如果是一问一答,比如从 memcached 取一个值,那么“重叠 IO”并不能降低响应时间,因为你无论如何要等 memcached 的回复。这时我们可以用别的方式来提高并发度,见问题8。(虽然不能降低响应时间,但也不要浪费线程在空等上,对吧)

另外以上的例子也说明,BlockingQueue 是构建多线程程序的利器。



6. 为什么第三方库往往要用自己的线程?
往往因为 event loop 模型没有标准实现。如果自己写代码,尽可以按所用 Reactor 的推荐方式来编程,但是第三方库不一定能很好地适应并融入这个 event loop framework。有时需要用线程来做一些串并转换。

对于 Java,这个问题还好办一些,因为 thread pool 在 Java 里有标准实现,叫 ExecutorService。如果第三方库支持线程池,那么它可以和主程序共享一个 ExecutorService ,而不是自己创建一堆线程。(比如在初始化时传入主程序的 obj。)对于 C++,情况麻烦得多,Reactor 和 Thread pool 都没有标准库。



例1:libmemcached 只支持同步操作

libmemcached 支持所谓的“非阻塞操作”,但没有暴露一个能被 select/poll/epoll 的 file describer,它的 memcached_fetch 始终会阻塞。它号称 memcached_set 可以是非阻塞的,实际意思是不必等待结果返回,但实际上这个函数会同步地调用 write(),仍可能阻塞在网络 IO 上。

如果在我们的 reactor event handler 里调用了 libmemcached 的函数,那么 latency 就堪忧了。如果想继续用 libmemcached,我们可以为它做一次线程封装,按问题 5 例 2 的办法,同额外的线程专门做 memcached 的 IO,而程序主体还是 reactor。甚至可以把 memcached “数据就绪”作为一个 event,注入到我们的 event loop 中,以进一步提高并发度。(例子留待问题 8 讲)

万幸的是,memcached 的协议非常简单,大不了可以自己写一个基于 reactor 的客户端,但是数据库客户端就没那么幸运了。



例2:MySQL 的官方 C API 不支持异步操作

MySQL 的客户端只支持同步操作,对于 UPDATE/INSERT/DELETE 之类只要行为不管结果的操作(如果代码需要得知其执行结果则另当别论),我们可以用一个单独的线程来做,以降低服务线程的延迟。可仿照前面 memcached_set 的例子,不再赘言。麻烦的是 SELECT,如果要把它也异步化,就得动用更复杂的模式了,见问题 8。

相比之下,PostgreSQL 的 C 客户端 libpq 的设计要好得多,我们可以用 PQsendQuery() 来发起一次查询,然后用标准的 select/poll/epoll 来等待 PQsocket,如果有数据可读,那么用 PQconsumeInput 处理之,并用 PQisBusy 判断查询结果是否已就绪,最后用 PQgetResult 来获取结果。借助这套异步 API,我们可以很容易地为 libpq 写一套 wrapper,使之融入到程序所用的 reactor 模型中。



7. 什么是线程池大小的阻抗匹配原则?
我在《常用模型》中提到“阻抗匹配原则”,这里大致讲一讲。

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

以后我再讲这个经验公式是怎么来的,先验证边界条件的正确性。

假设 C = 8, P = 1.0,线程池的任务完全是密集计算,那么 T = 8。只要 8 个活动线程就能让 8 个 CPU 饱和,再多也没用,因为 CPU 资源已经耗光了。

假设 C = 8, P = 0.5,线程池的任务有一半是计算,有一半等在 IO 上,那么 T = 16。考虑操作系统能灵活合理地调度 sleeping/writing/running 线程,那么大概 16 个“50% 繁忙的线程”能让 8 个 CPU 忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下文切换的开销而降低性能。

如果 P < 0.2,这个公式就不适用了,T 可以取一个固定值,比如 5*C。



另外,公式里的 C 不一定是 CPU 总数,可以是“分配给这项任务的 CPU 数目”,比如在 8 核机器上分出 4 个核来做一项任务,那么 C=4。



8. 除了你推荐的 reactor + thread poll,还有别的 non-trivial 多线程编程模型吗?
有,Proactor。

如果一次请求响应中要和别的进程打多次交道,那么 proactor 模型往往能做到更高的并发度。当然,代价是代码变得支离破碎,难以理解。

这里举 http proxy 为例,一次 http proxy 的请求如果没有命中本地 cache,那么它多半会:

解析域名 (不要小看这一步,对于一个陌生的域名,解析可能要花半秒钟)
建立连接
发送 HTTP 请求
等待对方回应
把结果返回客户
这 5 步里边跟 2 个 server 发生了 3 次 round-trip:

向 DNS 问域名,等待回复;
向对方 http 服务器发起连接,等待 TCP 三路握手完成;
向对方发送 http request,等待对方 response。
而实际上 http proxy 本身的运算量不大,如果用线程池,池中线程的数目会很庞大,不利于操作系统管理调度。

这时我们有两个解决思路:

把“域名已解析”,“连接已建立”,“对方已完成响应”做成 event,继续按照 Reactor 的方式来编程。这样一来,每次客户请求就不能用一个函数从头到尾执行完成,而要分成多个阶段,并且要管理好请求的状态(“目前到了第几步?”)。
用回调函数,让系统来把任务串起来。比如收到用户请求,如果没有命中本地 cache,立刻发起异步的 DNS 解析 startDNSResolve(),告诉系统在解析完之后调用 DNSResolved() 函数;在 DNSResolved() 中,发起连接,告诉系统在连接建立之后调用 connectionEstablished();在 connectionEstablished() 中发送 http request,告诉系统在收到响应之后调用 httpResponsed();最后,在 httpResponsed() 里把结果返回给客户。.NET 大量采用的 Begin/End 操作也是这个编程模式。当然,对于不熟悉这种编程方式的人,代码会显得很难看。Proactor 模式的例子可看 boost::asio 的文档,这里不再多说。
Proactor 模式依赖操作系统或库来高效地调度这些子任务,每个子任务都不会阻塞,因此能用比较少的线程达到很高的 IO 并发度。

Proactor 能提高吞吐,但不能降低延迟,所以我没有深入研究。



9. 模式 2 和模式 3a 该如何取舍?
这里的“模式”不是 pattern,而是 model,不巧它们的中译是一样的。《适用场合》中提到,模式 2 是一个多线程的进程,模式 3a 是多个相同的单线程进程。

我认为,在其他条件相同的情况下,可以根据工作集 (work set) 的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。

如果工作集较大,那么就用多线程,避免 CPU cache 换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。

例如,memcached 这个内存消耗大户用多线程服务端就比在同一台机器上运行多个 memcached instance 要好。(除非你在 16G 内存的机器上运行 32-bit memcached,那么多 instance 是必须的。)

又例如,求解 Sudoku 用不了多大内存,如果单线程编程更方便的话,可以用单线程多进程来做。再在前面加一个单线程的 load balancer,仿 lighttpd + fastcgi 的成例。



线程不能减少工作量,即不能减少 CPU 时间。如果解决一个问题需要执行一亿条指令(这个数字不大,不要被吓到),那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情况,我们能让工期提早结束。这听上去像统筹方法,确实也正是统筹方法。
————————————————
原文链接:https://blog.csdn.net/Solstice/article/details/5343217
回复

使用道具 举报

pikachuu 2022-5-9 07:01:11 来自APP | 显示全部楼层
本楼: 👍   0% (0)
 
 
0% (0)   👎
全局: 👍   79% (69)
 
 
20% (18)    👎
赞👍 收藏
回复

使用道具 举报

taoloveit 2022-6-23 12:07:20 来自APP | 显示全部楼层
本楼: 👍   0% (0)
 
 
0% (0)   👎
全局: 👍   84% (53)
 
 
15% (10)    👎
感谢lz总结 请问nuro 面了什么多线程题目?
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册账号
隐私提醒:
  • ☑ 禁止发布广告,拉群,贴个人联系方式:找人请去🔗同学同事飞友,拉群请去🔗拉群结伴,广告请去🔗跳蚤市场,和 🔗租房广告|找室友
  • ☑ 论坛内容在发帖 30 分钟内可以编辑,过后则不能删帖。为防止被骚扰甚至人肉,不要公开留微信等联系方式,如有需求请以论坛私信方式发送。
  • ☑ 干货版块可免费使用 🔗超级匿名:面经(美国面经、中国面经、数科面经、PM面经),抖包袱(美国、中国)和录取汇报、定位选校版
  • ☑ 查阅全站 🔗各种匿名方法

本版积分规则

>
快速回复 返回顶部 返回列表