并发带来的问题

252 阅读3分钟

并发带来的问题

并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。

在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,会面临非常多的问题,比如:

  1. 上下文切换的问题
  2. 死锁的问题
  3. 硬件和软件的资源限制问题

上下文切换

即使是单核处理器也支持多线程执行代码,CPU 通过给每个线程分配 时间片 来实现这个机制。

时间片:CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停的切换线程执行,给我们的直观感受就是多个线程在同时执行,时间片一般是几十毫秒(ms)。

CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便于下一次切换回这个任务时,可以再加载这个任务状态。所以任务从保存到再加载的过程就是一次上下文切换

上下文切换会影响多线程的执行速度!

多线程一定快吗?

package com.pangu;

import lombok.extern.slf4j.Slf4j;

/**
 * Created by etfox on 2021/10/25 16:44
 **/
@Slf4j
public class TestMain {
    private static final long COUNT = 10000L;

    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }

    private static void concurrency() throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread thread = new Thread(() -> {
            int a = 0;
            for (long i = 0; i < COUNT; i++) {
                a += 5;
            }
        });
        thread.start();
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        thread.join();
        long time = System.currentTimeMillis() - start;
        log.info("concurrency: {}ms, b={}", time, b);
    }

    private static void serial() {
        long start = System.currentTimeMillis();
        int a = 0;
        for (long i = 0; i < COUNT; i++) {
            a += 5;
        }
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        long time = System.currentTimeMillis() - start;
        log.info("serial: {}ms, b={}, a={}", time, b, a);
    }
}

测试结果

循环次数串行执行耗时 /ms并发执行耗时并发比串行快多少
1 亿276ms135ms约一倍
1 千万29ms16ms约一倍
1 百万4ms3ms差不多
10 万1ms2ms
1 万0ms1ms

如上可知,当并发执行累加操作不超过百万次时,速度会比串行执行累加操作要慢。主要慢在线程的创建和上下文切换的开销。

如何减少上下文切换

  1. 无锁编程
    • 多线程竞争时,会引起上下文的切换,所以多线程处理数据的时候,可以用一些办法来避免使用锁,如将数据的 ID 按照 hash 算法取模分段,不同的线程处理不同段的数据。
  2. CAS 算法
    • Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  3. 使用最少的线程
    • 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  4. 使用协程
    • 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

死锁

锁,是个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但是同时它会带来一些困扰,那就是可能出现死锁。一单产生死锁,就会造成系统不可用。(t1 t2 互相等待获取锁)。

概念

死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者彼此通信而造成的一种阻塞的现象,若无外力作用,这种状态会永久保持下去,此时就称为系统产生了死锁。

死锁产生的必要条件

  • 互斥条件

某个资源在被占用后,如果其他线程请求该资源,只能等待资源被释放

  • 请求和保持条件

线程在持有某个资源 A 后,但又想请求一个资源 B,但资源 B 已经被其他线程持有,此时请求将被阻塞,但线程对资源 A 的持有保持不放

  • 不剥夺条件

线程在持有某个资源时,在未释放前,不能被剥夺

  • 环路等待条件

指发生死锁时,必然存在一个环路等待条件,即 t1 -> t2 ... tn -> t1

避免死锁的常见方法

  • 避免一个线程同时持有多个锁
  • 避免一个线程在锁内部同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制
  • 对于数据库锁,加锁和解锁必须在同一个数据库连接中,否则会出现解锁失败的情况

资源限制的挑战

什么是资源限制?

资源限制是指在并发编程时,程序的执行速度受计算机资源或者软件资源的限制。

例如:服务器带宽 2Mb/s,某个资源的下载速度 1Mb/s,系统启动 10 个线程也不会让下载速度变成 10 Mb/s。所以在进行并发编程时,要考虑这些资源的限制。

硬件资源限制:

  • 带宽上传/下载速度
  • 硬盘读写速度
  • CPU 处理速度

软件资源限制:

  • 连接数
  • socket 连接数等

资源限制引发的问题?

  • 在并发编程中,提升执行速度的原则是,将串行执行变成并发执行。但是把某段代码并发执行,因为受限于资源,仍然串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。

如何解决资源限制的问题?

  • 对于硬件资源的限制,可以考虑使用集群并行执行程序。
  • 对于软件资源限制,可以考虑使用池化思想,将资源复用。

在资源限制的情况下进行编程?

  • 根据不同的资源限制调整程序的并发度,比如下载文件程序依赖两个资源,带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果 SQL 语句执行非常快,而线程数量比数据库连接数大时,则某些线程会被阻塞等待数据库连接。