分布式系统基础(一)

《分布式系统常用技术及案例分析(第2版)》,带我走进分布式系统的第一本书。

    • 分布式系统概述
      • 设计分布式系统时需要考虑的问题
      • 分布式系统面临的挑战
    • 进程、线程、纤程
      • Java 编程中的线程对象
        • Java 两种创建Thread实例的方式
        • 中断 (interrupt)
    • 进程间通信(IPC)
      • 基本概念理解
        • 理解同步、异步、阻塞、非阻塞
        • 理解用户空间和内核空间
        • 缓存IO
      • 网络I/O模型
        • UNIX I/O模型
        • Java I/O 模型演进
          • 阻塞I/O 模型:
          • 非阻塞 I/O 模型(NIO)
          • 异步I/O 模式
      • RPC 远程过程调用
      • 面向消息的通信(利用消息中间件通信)
        • Java Message Service(JMS)

分布式系统概述

分布式系统是若干独立计算机的集合,这些计算机对于用户来说就像单个系统。

	● 硬件独立:机器本身是独立的,或者在容器世界中运行容器是独立的(资源隔离)
	● 软件统一:扩展和升级都比较容易,并且对用户来说是无感的

集中式系统:”一荣俱荣,一损俱损“,存在单点故障风险

设计分布式系统时需要考虑的问题

系统如何拆分,系统怎么通信、怎么保证通信安全、可扩展、可靠性、数据一致性

分布式系统面临的挑战

  • 异构性:需要一个统一的通信协议来屏蔽底层硬件、网络、语言的异构
  • 缺乏全局时钟:由于网络通信,势必会有延迟,时钟同步问题(尽可能同步)
  • 一致性:数据分散,主备分离,分布式系统需要解决数据一致性的问题
  • 开放:分布式系统的各个部分都由不同的人开发,大家需要遵循一定的规范
  • 安全:数据网络传输时需要加密,防止服务攻击。
  • 可扩展性:系统需要满足业务增长场景下的易扩展需求

进程、线程、纤程

进程 有一个独立的执行环境。每个进程都有自己的内存空间。多个进程并发共享同一个CPU和其他硬件资源,操作系统支持进程之间的隔离。

\qquad 进程间通信(Inter Process Communication, IPC),如管道和socket。

线程 是程序执行流的最小单元,是轻量级进程。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。

创建一个新的线程比创建一个新的进程需要更少的资源。线程系统一般只维护用来让多个线程共享CPU所必需的最少量信息,特别是线程上下文(Thread Context)中一般只包含CPU上下文及某些其他线程管理信息。

线程 存在于进程中,每个进程至少有一个线程。线程共享进程的资源,包括内存,所以线程是抢占式的。线程之间没有相互隔离,所以多线程开发中需要考虑共享数据的安全性。

纤程 可以理解为比线程颗粒度更细的并发单元。由用户自己定义调度算法,内核无感知,非抢占式,提供高并发能力的同时伴随着调度设计的复杂工作。

一个线程可以包含一个或多个纤程。线程每次执行哪一个纤程的代码,是由用户来决定的。

Java 编程中的线程对象

每个线程都与Thread类的一个实例相关联。

Java 两种创建Thread实例的方式

  1. 提供Runnable对象

  2. 继承Thread类
    Thread 类本身是 Runnable 的实现

    package org.thread;
    public class CreateThreadDemo {
        public static void main(String[] args) {
            // 提供Runnable对象
            MyRunnable myRunnable = new MyRunnable();
            Thread thread = new Thread(myRunnable);
            thread.start();
            // 继承Thread类
            MyThread myThread = new MyThread();
            myThread.start();
            System.out.println("test end.");
        }
    	// 提供Runnable对象
        public static class MyRunnable implements Runnable {
            @Override
            public void run() {
                System.out.println("Create By MyRunnable implements Runnable.");
            }
        }
    	// 继承Thread类
        public static class MyThread extends Thread{
            @Override
            public void run() {
                System.out.println("Creat By MyThread extends Thread");
            }
        }
    }
    

线程生命周期:新建、就绪、运行、阻塞、销毁。
分布式系统基础(一)_第1张图片

  1. 创建:新建出来一个线程实例
  2. 就绪:线程准备就绪,具备运行条件,等待处理机
  3. 运行:线程已经获得 CPU 时间片,处理机正在处理该线程的逻辑
  4. 阻塞:线程此时在等待一个事件或信号,逻辑上是不可执行的,缺少条件,当条件满足后,该线程会再次进入就绪状态,等带处理机的执行
  5. 销毁:线程正常结束、被强制终止、出现异常,都会被销毁,并释放资源。

中断 (interrupt)

中断表明一个线程应该停止它正在做和将要做的事。

Java 对线程中断的的支持:

  1. InterruptedException异常,当线程被中断时,可以抛出该异常,并清除中断标志位

    例如 sleep() 方法在中断时会抛出该异常,终止自己正在做的事情并返回。

  2. Thread 实例方法 isInterrupted():
    判断当前线程对象的中断标志位是否被标记了,如果被标记了则返回true表示当前已经被中断,否则返回false。
  3. Thread 实例方法 interrupt():
    设置当前线程对象的中断标识位为 true,表示中断该线程
  4. Thread 静态方法 interrupted():
    返回当前线程是否被中断,并且该方法调用结束的时候会清空中断标识位。

代码示例:

package org.thread;

import org.junit.Test;

public class InterruptThreadDemo {
    /**
     * 1. 测试线程处于创建状态时
     */
    @Test
    public void InterruptWhenNew(){
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("InterruptWhenNew Running....");
            }
        });
        Thread.State state = thread.getState();
        System.out.println(state); // 线程状态

        thread.interrupt(); // 调用实例 interrupt() 方法,设置中断标志位

        System.out.println(thread.isInterrupted());// 检查中断标志位是否被设置
    }

    /**
     * 2. 测试线程处于销毁状态时
     */
    @Test
    public void InterruptWhenTerminated() throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("InterruptWhenTerminated Running....");
            }
        });
        thread.start();
        System.out.println(thread.getState()); // 线程状态
        thread.join(); //等 thread 执行结束
        System.out.println(thread.getState()); // 线程状态

        thread.interrupt(); // 调用实例 interrupt() 方法,设置中断标志位

        System.out.println(thread.isInterrupted());// 检查中断标志位是否被设置
    }

    /**
     * 3.1. 测试线程处于就绪状态时中断(也有可能是在运行时接到中断信号)
     *      从结果可以看出,就绪的线程接到中断会设置中断标记,但是不会真的停止运行
     */
    @Test
    public void InterruptWhenRunnable() throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){
                    //System.out.println("InterruptWhenRunnable Running....");
                }
            }
        });
        thread.start();
        System.out.println(thread.getState()); // 线程状态   RUNNABLE
        thread.interrupt(); // 调用实例 interrupt() 方法,设置中断标志位, 中断线程 thread

        Thread.sleep(1000); //等到thread线程被中断之后
        System.out.println(thread.isInterrupted());// 检查中断标志位是否被设置  true
        System.out.println(thread.getState()); // 线程状态   RUNNABLE

        thread.join();

    }

    /**
     * 3.2. 测试线程处于就绪状态时中断,且由线程中判断中断标志,由程序实现中断逻辑
     *      线程一旦发现自己被中断(中断标记被设置了),就立刻跳出死循环。怎强了线程的控制能力
     */
    @Test
    public void InterruptWhenRunnable2() throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){
                    if (Thread.interrupted()){
                        System.out.println("Thread InterruptWhenRunnable is interrupted.Break the while");
                        break;
                    }
                    System.out.println("InterruptWhenRunnable2 is running...");
                }
            }
        });
        thread.start();
        System.out.println(thread.getState()); // 线程状态  RUNNABLE
        Thread.sleep(1000);
        System.out.println(thread.getState()); // 线程状态  RUNNABLE
        thread.interrupt(); // 调用实例 interrupt() 方法,设置中断标志位, 中断线程 thread

        Thread.sleep(1000); //等到thread线程被中断之后
        System.out.println(thread.isInterrupted());// 检查中断标志位是否被设置 false
        System.out.println(thread.getState()); // 线程状态  TERMINATED
    }

    /**
     * 4. 测试线程处于阻塞状态时中断
     *      线程中断标记会被设置为true
     *      但是线程依然可以在满足条件时进入Runnable状态,并执行
     *      所以,也需要在程序中做中断判断,并做相应处理,以支持线程中断
     */
    @Test
    public void InterruptWhenBlocked() throws InterruptedException {
        MyLockedThread holdLockThread = new MyLockedThread();
        holdLockThread.setName("t1");
        holdLockThread.start();// 第一个线程可以获得锁,并一直执行,不释放锁

        MyLockedThread canNotGetLockBlockedThread = new MyLockedThread();
        canNotGetLockBlockedThread.setName("t2");
        canNotGetLockBlockedThread.start();// 第二个线程,无法获得锁,一直阻塞

        Thread.sleep(1000);  // 等一下

        System.out.println("holdLockThread:" + holdLockThread.getState());
        System.out.println("canNotGetLockBlockedThread:"+canNotGetLockBlockedThread.getState());

        canNotGetLockBlockedThread.interrupt();

        System.out.println(canNotGetLockBlockedThread.isInterrupted());

        System.out.println("holdLockThread:" + holdLockThread.getState());
        System.out.println("canNotGetLockBlockedThread:"+canNotGetLockBlockedThread.getState());

        Thread.sleep(2000);

        holdLockThread.stop(); // 终止 t1 ,让 t2 获得锁:结果发现,t2 虽然修改了中断标记位,但是还是正常的执行了

        Thread.sleep(1000);
        System.out.println("canNotGetLockBlockedThread:"+canNotGetLockBlockedThread.getState());
        canNotGetLockBlockedThread.join();

    }
    /**
     * 定义一个线程,run 中调用一个需要获取锁的方法 doSomething
     * doSomething 中一直死循环,一直占有锁,可以阻塞其他线程
     */
    public static class MyLockedThread extends Thread {
        public synchronized static void doSomething(){
            while (true){
                // 死循环,一直占锁
                String name = Thread.currentThread().getName();
                if ("t2".equals(name)){
                    System.out.println(name + " is running");
                }
            }
        }
        @Override
        public void run() {
            doSomething();
        }
    }
}

总结:
Java中的中断机制提供了中断标记位。如何实现线程支持自己的中断,需要根据线程所做的任务和具体需求由程序自己定义:可以是捕捉 InterruptedException 异常,直接返回或做一些操作再返回;也可以通过 Thread.interrupted() 监控线程是否被中断,适用于长时间没有调用方法抛出 InterruptedException 异常的场景。

进程间通信(IPC)

基本概念理解

理解同步、异步、阻塞、非阻塞

同步与异步

描述的是用户线程与内核的交互方式

同步:指用户线程发起I/O请求后需要等待,或者轮询内核I/O操作完成后才能继续执行;

异步:指用户线程发起I/O请求后仍继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞与非阻塞

描述的是用户线程调用内核I/O操作的方式

阻塞:I/O操作需要彻底完成后才返回用户空间

非阻塞:I/O操作被调用后立即返回给用户一个状态值,无须等到I/O操作彻底完成。线程在等待 IO 的时候,可以同时做其他任务。

分布式系统基础(一)_第2张图片

以上两个图都是阻塞 I/O,请求线程需要等待内核I/O操作的返回才能继续执行下面的代码。

总结:IO一般可分为两个步骤:发起IO请求实际的IO操作
1. 同步与异步区别在于第二个步骤是否阻塞,即实际的IO操作是否阻塞请求线程
2. 阻塞与非阻塞区别在于在于第一步,也就是发起I/O请求是否会被阻塞,如果发起IO请求需要等待返回

理解用户空间和内核空间

为了内核安全,防止用户进程直接操作内核,Linux操作系统将内存分为内核空间和用户空间,Linux 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间。两者不能简单地使用指针访问数据。

用户空间和内核空间指的是 CPU 访问的逻辑地址(即空间),这些空间通过地址映射表映射到相应的物理地址(即物理内存)。

32 位系统
2 32 2^{32} 232 共可以映射 4G 的虚拟地址空间, 3G 用户空间, 1G 内核空间
64 位系统(只利用 48 bit 地址):
2 48 2^{48} 248 共可以映射 256T 内存空间,128T 用户空间; 128T 内空间
一位映射一个字节的物理地址, 2 48 ( 字 节 ) = 256 × 2 10 × 2 10 × 2 10 × 2 10 = 256 T 2^{48}(字节) = 256 \times 2^{10} \times 2^{10} \times 2^{10} \times 2^{10} = 256T 248()=256×210×210×210×210=256T

X86 32位系统,有限的逻辑空间访问跟多的物理空间——Linux内核高端内存:
分布式系统基础(一)_第3张图片
思想:借一段地址空间(128MB高端内存地址空间),建立临时地址映射,用完后释放,达到这段地址空间可以循环使用,访问所有物理内存。

内核空间和用户空间推荐

内核态与用户态
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

  • 在内核态下,此时 CPU 可以执行任何指令。可以自由地访问任何有效地址。(其实是用户程序委托内核做一些事情,这些事用户程受限)
  • 在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

其实所有的系统资源管理都是在内核空间中完成的,比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。用户程序从用户态切换到内核态的三种方式:

  1. 系统调用
  2. 软中断
  3. 硬件中断

分布式系统基础(一)_第4张图片
用户程序发起系统调用(比如读文件,IO操作)时,从用户态切换到内核态最后再切回用户态,在此过程中:先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。

缓存IO

有被称为标准IO,大多数文件系统的默认 IO 操作都是缓存 IO。
在 Linux 的缓存 IO 机制中,数据会先被拷贝到系统内核的缓冲区,然后才会从操作系统内核缓冲区拷贝到应用程序的内存空间

优点:在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全
缺点:多次拷贝,CPU及内存开销大。

网络IO和磁盘IO理解推荐

网络I/O模型

UNIX I/O模型

参考:UNIX NetworkProgramming, Volume 1, Second Edition: Networking APIs: Sockets and XTI
五中 IO 模型一览表
分布式系统基础(一)_第5张图片

  1. 阻塞 I/O 模型
    从发起 IO 请求开始直到接收到内核返回一直阻塞,等待内核完成实际 IO 操作,期间包括两个阶段:

    i . 等待内核准备数据
    ii. 等待内核将数据从不敢内核缓冲区复制到应用内存。
    分布式系统基础(一)_第6张图片
    总结:请求无法立即完成则保持阻塞。

  2. 非阻塞I/O 模型

    操作系统对非阻塞 IO 的支持:在连接 socket 时设置相关参数可以告诉内核,该 IO 请求的 IO 操作不要阻塞

    如果请求的数据无法立刻准备好,内核的 IO 操作进程不要进入睡眠状态,而是立刻返回一个错误状态码,这样用户的 IO 请求不会被阻塞。

    但是在数据准备好时,用户的 IO 请求会被阻塞,等待内核将数据复制到应用内存后返回。
    分布式系统基础(一)_第7张图片
    该模型很少单独使用,它是 I/O 多路复用的基础。

  3. I/O复用(select和poll)模型

    应用线程 阻塞于 select 或 poll 系统调用函数,不是阻塞于真正的 I/O 系统调用。

    这两个函数可以同时阻塞多个 I/O 操作,并且可以同时对多个读操作、多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。
    分布式系统基础(一)_第8张图片

    调用select或poll函数的方法由一个用户态线程负责轮询多个socket,直到阶段1的数据就绪,再通知实际的用户线程执行阶段2的复制操作。通过一个专职的用户态线程执行非阻塞I/O轮询,模拟实现阶段1的异步化。

    总结:类似同步阻塞IO模型,但是最大优势:用户可以注册多个socket,然后不断地调用select来读取被激活的socket,达到一个线程内同时处理多个socket的I/O请求的目的(同步阻塞模型需要使用多线程来实现)。

  4. 信号驱动I/O(SIGIO)模型

    特点:信号处理程序、发出信号通知用户程序发起系统调用、用户程序阻塞于发起的系统调用
    分布式系统基础(一)_第9张图片
    信号通知用户程序 IO 操作什么时候开始。

  5. 异步I/O(Posix.1的aio_系列函数)模型

    在整 IO 操作全部完成之后,告诉用户应用 I/O 操作已将完成,通知应用程序来读数据。

    异步I/O是POSIX规范定义的。使用Proactor设计模式实现。

分布式系统基础(一)_第10张图片

Java I/O 模型演进

阻塞I/O 模型:

单线程(所有连接都使用主线程): recv 阻塞会导致其他链接全部阻塞,无法链接服务。

本质问题:阻塞,用户线程要等待内核系统调(实际IO操作)用的完成

【优化方案】

BIO + 多线程
改进:突破 BIO 模型一次只能处理一个连接的缺点
问题:资源消耗与连接数正相关,当有大量的短连接出现时,性能比较低。
BIO + 线程池
改进:利用线程池重用线程避免了频繁地创建和销毁线程带来的开销,在大量短连接的场景中性能会提升
问题:如果出现大量长连接占用线程池中的线程,使线程池中无空闲线程,会阻塞其他连接。

【示例代码地址:Java BIO 模型示例】

非阻塞 I/O 模型(NIO)

BIO 的几个优化方案虽然一定程度上提示了用户程序处理连接的能力,但是都有一个共同的问题:阻塞,每个连接都需要对应要给线程来处理,且处理时是阻塞的(等待准备时不知道什么时候准备好,一直傻傻的干等)。

在高并发(持续大量的连接同时请求)场景中,之前的两种 BIO 优化方案都需要消耗大量的线程来维持连接。并且 CPU 在线程切换上消耗很大。

Java NIO 模型的主要优势:少量的线程就可以处理大量连接的请求

主要组成:

  1. Channel 通道:IO 传输发生时数据通过的入口
  2. Buffer 缓冲区:可以理解为数据在管道传输时的起点和终点
  3. Selector 选取器(IO监听器):负责监听 IO 事件

所用通道都向 Selector 注册,Selector 负责轮询检测,然后服务端进程会阻塞在 Selector 的 select() 方法,直到注册的通道有事件就绪。
分布式系统基础(一)_第11张图片
【示例代码地址:Java NIO 模型代码示例】

异步I/O 模式

前面的JavaIO方案都是同步的,用户线程需要等待内核的 IO 操作。

Java SE 7之后的版本,引入了对异步 I/O(NIO.2)的支持。

非阻塞IO模型,可以让用户线程知道什么时候可以开始和内核交互数据,即数据什么时候准备好
异步 IO 模型,可以让用户线程知道,IO操作什么时候完全结束(彻底完成)

RPC 远程过程调用

一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC 假定一些其他协议的存在(如TCP/UDP,HTTP),以此在通信程序之间携带数据。

RPC 协议跨越网络模型的应用层和传输层。可以利用 Http 协议携带数据,也可以使用 TCP 协议,他们的共同特点是屏蔽网络层的细节,让远程过程调用像本地过程调用一样,不同之处在数据的组织形式、协议不同。

基于 HTTP 的优势是更灵活,基于 TCP 的优势是更高效。

RPC 调用步骤流程图:
分布式系统基础(一)_第12张图片
RPC 框架将 步骤2~15 封装起来。

RPC 通信属于同步调用方式:调用方需要等待被调用方返回:
分布式系统基础(一)_第13张图片

实现 RPC 需要考虑的问题:

  1. 参数如何传递
    值类型、引用类型怎么考虑? 序列化器
  2. 如何表示数据
    分布式系统中,异构系统通信,数据兼容问题。需要一个”标准“来编码,并作为参数传递。
    隐式(不传递名称,只传数据值):XDR、NDR
    显示(传递字段名称和值):JSON、Google Protocol Buffer、基于XML的数据表示格式
  3. 如何选用传输协议
    TCP/UDP:大多数 RPC 框架使用
    HTTP:如 gRPC 框架使用 HTTP2 协议
  4. 远程调用出错时怎么处理及远程调用的语义
    至少一次、最多一次、介于二者之间
    远程调用函数的幂等性问题(例如远程调用写文件,这是一个非幂等的远程调用,多次调用会出错)
  5. 性能与安全

常见的RPC框架:

传统的webservice框架
apache CXF、apache Axis2
新兴的微服务框架
Dubbo、springcloud(restfull)、apache Thrift、ICE、GRPC

REST 与 RPC:
REST 是一种架构风格,REST规范把所有内容都视为资源,网络上一切皆资源。RPC 时一种协议,一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。他们没有特殊的联系,本质都是网络交互的协议规范。

  • 侧重点:
    REST 的主题是资源,是面向资源的设计,RPC 侧重于动作(调用)
  • 传输效率上
    RPC 可以使用传输层做数据交互,效率会更高
  • 复制度:
    RPC 实现复杂,过程繁琐,而 REST 风格比较灵活简单
    RPC 需要实现编码,序列化,网络传输等,而 RESTful 不要关注这些。

应用场景:RPC 更适合服务内部调用、REST 更适合对外提共接口服务

面向消息的通信(利用消息中间件通信)

问题:
当无法保证发出请求时接收端一定正在执行的情况下,RPC 的同步调用特性会造成客户端在发出的请求在得到处理之前就被阻塞了,因而有时也需要采取其他的办法。
例如:服务端的服务性能跟不上客户端发送的请求时,客户端的请求会被阻塞,这也会对服务端带来很大的压力。

面向消息的通信,提供一种异步的通信方式。一般由消息队列系统或面向消息的中间件提供高效可靠的消息传递机制。使得程序间没有直接的联系,所以它们不必同时运行。

常见的MQ或MOM产品:

  1. Java Message Service(JMS)
  2. Apache ActiveMQ
  3. Apache RocketMQ
  4. RabbitMQ
  5. Apache Kafka

Java Message Service(JMS)

Java面向消息中间件的API,用于两个或多个客户端之间发送消息。JMS是一系列的接口及相关语义的集合。

支持的消息模式:

  1. 点对点(Point-to-Point, PTP)消息风格
  2. 发布订阅(Publish/Subscribe, Pub/Sub)消息风格

接口源码包位置:javax.jms

JMS组成结构:

  • JMS provider JMS消息中间件(ActiveMQ,rabbitMQ,kafka等实现JMS接口的消息中间件)
  • JMS producer 消息生产者
  • JMS consumer 消息消费者
  • JMS message 消息

JMS message:消息头,消息体,消息属性

JMS的可靠性:利用 持久化/ACK确认机制/事务 来保证消息的可靠性

你可能感兴趣的:(java,网络,分布式)