4

OS中的进程、线程

  • 进程:即处于执行期的程序,且包含其他资源,如打开的文件、挂起的信号、内核内部数据、处理器状态、内核地址空间、一个或多个执行的线程、数据段。
  • 线程:进程中的活动对象,内核调度的对象不是进程而是线程;传统Unix系统一个进程只包含一个线程。

线程在Linux中的实现

从Linux内核的角度来说,并没有线程这个概念。Linux把所有的线程都当做进程来实现,内核没有为线程准备特别的调度算法和特别的数据结构。线程仅仅被视为一个与其他进程共享某些资源的进程。所以,在内核看来,它就是一个普通的进程。

在Windows或Solaris等操作系统的实现中,它们都提供了专门支持线程的机制(lightweight processes)。

写时拷贝

传统的fork()系统调用直接把所有资源复制给新创建的进程,效率十分低下,因为拷贝的数据也许并不需要。

Linux的fork()使用写时拷贝实现。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。

只有在需要写入的时候,数据才会被复制,在此之前,只是以只读方式共享。这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间常常包含几十M的数据)。

因此,Linux创建进程和线程的区别就是共享的地址空间、文件系统资源、文件描述符、信号处理程序等这些不同。

以下是StackOverflow上的一个答案:

alt text

即,在Linux下,进程使用fork()创建,线程使用pthread_create()创建;fork()pthread_create()都是通过clone()函数实现,只是传递的参数不同,即共享的资源不同。(Linux是通过NPTL实现POSIX Thread规范,即通过轻量级进程实现POSIX Thread,使之前在Unix上的库、软件可以平稳的迁移到Linux上)

Java线程如何映射到OS线程

JVM在linux平台上创建线程,需要使用pthread 接口。pthread是POSIX标准的一部分它定义了创建和管理线程的C语言接口。Linux提供了pthread的实现:

pthread_t tid;
if (pthread_create(&tid, &attr, thread_entry_point, arg_to_entrypoint))
{
      fprintf(stderr, "Error creating thread\n");
      return;
}
  • tid是新创建线程的ID
  • attr是我们需要设置的线程属性
  • thread_entry_point是会被新创建线程调用的函数指针
  • arg_to_entrypoint是会被传递给thread_entry_point的参数

thread_entry_point所指向的函数就是Thread对象的run方法。

无返回值线程和带返回值的线程

  • 无返回值:一种是直接继承Thread,另一种是实现Runnable接口
  • 带返回值:通过Callable和Future实现

带返回值的线程是我们在实践中更常用的。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。

最常见的竞态条件类型就是“先检查后执行”(Check-Then-Act)操作,即通过一个可能失效的观测结果来决定下一步的动作。

使用“”先检查后执行“的一种常见情况就是延迟初始化:

public class LazyInitRace {
    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance() {
        if (instance == null) {
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

不要这么做。

Executor框架

使用裸线程的缺点

prod环境中,为每个任务分配一个线程的方法存在严重的缺陷,尤其是当需要创建大量的线程时:

  • 线程生命周期的开销非常高:线程的创建与销毁并不是没有代价的。
  • 资源消耗:会消耗内存和CPU,大量的线程竞争CPU资源将产生性能开销。如果你已经拥有足够多的线程使所有CPU处于忙碌状态,那么创建更多的线程反而会降低性能。
  • 稳定性:可创建的线程的数量上存在限制,包括JVM的启动参数、操作系统对线程的限制,如果超出这些限制,很可能会抛出OutOfMemoryError异常。

Executor基本原理

Executor基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程则相当于消费者。

线程池的构造函数如下:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              RejectedExecutionHandler handler) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), handler);
    }

线程池大小

  • corePoolSize:核心线程数,当线程池的线程数小于corePoolSize,直接创建新的线程
  • 线程数大于corePoolSize但是小于maximumPoolSize:如果任务队列还未满, 则会将此任务插入到任务队列末尾;如果此时任务队列已满, 则会创建新的线程来执行此任务。
  • 线程数等于maximumPoolSize:如果任务队列还未满, 则会将此任务插入到任务队列末尾;如果此时任务队列已满, 则会由RejectedExecutionHandler处理。

keep-alive

  • keepAliveTime:当我们的线程池中的线程数大于corePoolSize时, 如果此时有线程处于空闲(Idle)状态超过指定的时间(keepAliveTime), 那么线程池会将此线程销毁。

工作队列

工作队列(WorkQueue)是一个BlockingQueue, 它是用于存放那些已经提交的, 但是还没有空余线程来执行的任务。

常见的工作队列有一下几种:

  • 直接切换(Direct handoffs)
  • 无界队列(Unbounded queues)
  • 有界队列(Bounded queues)

在生产环境中,禁止使用无界队列,因为当队列中堆积的任务太多时,会消耗大量内存,最后OOM;通常都是设定固定大小的有界队列,当线程池已满,队列也满的情况下,直接将新提交的任务拒绝,抛RejectedExecutionException 出来,本质上这是对服务自身的一种保护机制,当服务已经没有资源来处理新提交的任务,因直接将其拒绝。

Java原生线程池在生产环境中的问题

在服务化的背景下,我们的框架一般都会集成全链路追踪的功能,用来串联整个调用链,主要是记录TraceIdSpanIdTraceIdSpanId一般都记录在ThreadLocal中,对业务方来说是透明的。

当在同一个线程中同步RPC调用的时候,不会存在问题;但如果我们使用线程池做客户端异步调用时,就会导致Trace信息的丢失,根本原因是Trace信息无法从主线程的ThreadLocal传递到线程池的ThreadLocal中。

对于这个痛点,阿里开源的transmittable-thread-local解决了这个问题,实现其实不难,可以阅读一下源码:

https://github.com/alibaba/transmittable-thread-local

性能与伸缩性

对性能的思考

提升性能意味着用更少的资源做更多的事情。“资源”的含义很广,例如CPU时钟周期、内存、网络带宽、磁盘空间等其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、IO密集型等。

使用多线程理论上可以提升服务的整体性能,但与单线程相比,使用多线程会引入额外的性能开销。包括:线程之间的协调(例如加锁、触发信号以及内存同步),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度地使用线程,其性能可能甚至比实现相同功能的串行程序更差。

从性能监视的角度来看,CPU需要尽可能保持忙碌状态。如果程序是计算密集型的,那么可以通过增加处理器来提升性能。但如果程序无法使CPU保持忙碌状态,那增加更多的处理器也是无济于事的。

可伸缩性

可伸缩性是指:当增加计算资源时(例如CPU、内存、存储容量、IO带宽),程序的吞吐量或者处理能力能响应的增加。

我们熟悉的三层模型,即程序中的表现层、业务逻辑层和持久层是彼此独立,并且可能由不同的服务来处理,这很好地说明了提高伸缩性通常会造成性能损失。如果把表现层、业务逻辑层和持久层都融合到某个单体应用中,在负载不高的时候,其性能肯定要高于将应用程序分为多层的性能。这种单体应用避免了在不同层次之间传递任务时存在的网络延迟,减少了很多开销。

然而、当单体应用达到自身处理能力的极限时,会遇到一个严重问题:提升它的处理能力非常困难,即无法水平扩展。

Amdahl定律

大多数并发程序都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器上,最高的加速比为:

alt text

当N趋近于无穷大时,最大的加速比趋近于1/F。因此,如果程序中有50%的计算需要串行执行,那么最高的加速比只能是2。

上下文切换

线程调度会导致上下文切换,而上下文切换是会产生开销的。若是CPU密集型程序产生大量的线程切换,将会降低系统的吞吐量。

UNIX系统的vmstat命令能够报告上下文切换次数以及在内核中执行时间的所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或者锁竞争导致的阻塞引起的。

>> vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 3235932 238256 3202776    0    0     0    11    7    4  1  0 99  0  0
 
 cs:每秒上下文切换次数
 sy:内核系统进程执行时间百分比
 us:用户进程执行时间百分比

以上。

原文链接

https://segmentfault.com/a/11...


扑火的蛾
272 声望30 粉丝

与世界分享你的装逼经验。