《分布式系统常用技术及案例分析(第2版)》,带我走进分布式系统的第一本书。
分布式系统是若干独立计算机的集合,这些计算机对于用户来说就像单个系统。
● 硬件独立:机器本身是独立的,或者在容器世界中运行容器是独立的(资源隔离)
● 软件统一:扩展和升级都比较容易,并且对用户来说是无感的
集中式系统:”一荣俱荣,一损俱损“,存在单点故障风险
系统如何拆分,系统怎么通信、怎么保证通信安全、可扩展、可靠性、数据一致性
进程 有一个独立的执行环境。每个进程都有自己的内存空间。多个进程并发共享同一个CPU和其他硬件资源,操作系统支持进程之间的隔离。
\qquad 进程间通信(Inter Process Communication, IPC),如管道和socket。
线程 是程序执行流的最小单元,是轻量级进程。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
创建一个新的线程比创建一个新的进程需要更少的资源。线程系统一般只维护用来让多个线程共享CPU所必需的最少量信息,特别是线程上下文(Thread Context)中一般只包含CPU上下文及某些其他线程管理信息。
线程 存在于进程中,每个进程至少有一个线程。线程共享进程的资源,包括内存,所以线程是抢占式的。线程之间没有相互隔离,所以多线程开发中需要考虑共享数据的安全性。
纤程 可以理解为比线程颗粒度更细的并发单元。由用户自己定义调度算法,内核无感知,非抢占式,提供高并发能力的同时伴随着调度设计的复杂工作。
一个线程可以包含一个或多个纤程。线程每次执行哪一个纤程的代码,是由用户来决定的。
每个线程都与Thread类的一个实例相关联。
提供Runnable对象
继承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");
}
}
}
中断表明一个线程应该停止它正在做和将要做的事。
Java 对线程中断的的支持:
例如 sleep() 方法在中断时会抛出该异常,终止自己正在做的事情并返回。
代码示例:
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 异常的场景。
同步与异步
描述的是用户线程与内核的交互方式
同步:指用户线程发起I/O请求后需要等待,或者轮询内核I/O操作完成后才能继续执行;
异步:指用户线程发起I/O请求后仍继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞与非阻塞
描述的是用户线程调用内核I/O操作的方式
阻塞:I/O操作需要彻底完成后才返回用户空间
非阻塞:I/O操作被调用后立即返回给用户一个状态值,无须等到I/O操作彻底完成。线程在等待 IO 的时候,可以同时做其他任务。
以上两个图都是阻塞 I/O,请求线程需要等待内核I/O操作的返回才能继续执行下面的代码。
总结:IO一般可分为两个步骤:发起IO请求 和 实际的IO操作
1. 同步与异步区别在于第二个步骤是否阻塞,即实际的IO操作是否阻塞请求线程
2. 阻塞与非阻塞区别在于在于第一步,也就是发起I/O请求是否会被阻塞,如果发起IO请求需要等待返回
为了内核安全,防止用户进程直接操作内核,Linux操作系统将内存分为内核空间和用户空间,Linux 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间。两者不能简单地使用指针访问数据。
用户空间和内核空间指的是 CPU 访问的逻辑地址(即空间),这些空间通过地址映射表映射到相应的物理地址(即物理内存)。
X86 32位系统,有限的逻辑空间访问跟多的物理空间——Linux内核高端内存:
思想:借一段地址空间(128MB高端内存地址空间),建立临时地址映射,用完后释放,达到这段地址空间可以循环使用,访问所有物理内存。
内核空间和用户空间推荐
内核态与用户态
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
- 在内核态下,此时 CPU 可以执行任何指令。可以自由地访问任何有效地址。(其实是用户程序委托内核做一些事情,这些事用户程受限)
- 在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
其实所有的系统资源管理都是在内核空间中完成的,比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。用户程序从用户态切换到内核态的三种方式:
用户程序发起系统调用(比如读文件,IO操作)时,从用户态切换到内核态最后再切回用户态,在此过程中:先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。
有被称为标准IO,大多数文件系统的默认 IO 操作都是缓存 IO。
在 Linux 的缓存 IO 机制中,数据会先被拷贝到系统内核的缓冲区,然后才会从操作系统内核缓冲区拷贝到应用程序的内存空间。
优点:在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全
缺点:多次拷贝,CPU及内存开销大。
网络IO和磁盘IO理解推荐
参考:UNIX NetworkProgramming, Volume 1, Second Edition: Networking APIs: Sockets and XTI
五中 IO 模型一览表
阻塞 I/O 模型
从发起 IO 请求开始直到接收到内核返回一直阻塞,等待内核完成实际 IO 操作,期间包括两个阶段:
非阻塞I/O 模型
操作系统对非阻塞 IO 的支持:在连接 socket 时设置相关参数可以告诉内核,该 IO 请求的 IO 操作不要阻塞
如果请求的数据无法立刻准备好,内核的 IO 操作进程不要进入睡眠状态,而是立刻返回一个错误状态码,这样用户的 IO 请求不会被阻塞。
但是在数据准备好时,用户的 IO 请求会被阻塞,等待内核将数据复制到应用内存后返回。
该模型很少单独使用,它是 I/O 多路复用的基础。
I/O复用(select和poll)模型
应用线程 阻塞于 select 或 poll 系统调用函数,不是阻塞于真正的 I/O 系统调用。
这两个函数可以同时阻塞多个 I/O 操作,并且可以同时对多个读操作、多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。
调用select或poll函数的方法由一个用户态线程负责轮询多个socket,直到阶段1的数据就绪,再通知实际的用户线程执行阶段2的复制操作。通过一个专职的用户态线程执行非阻塞I/O轮询,模拟实现阶段1的异步化。
总结:类似同步阻塞IO模型,但是最大优势:用户可以注册多个socket,然后不断地调用select来读取被激活的socket,达到一个线程内同时处理多个socket的I/O请求的目的(同步阻塞模型需要使用多线程来实现)。
信号驱动I/O(SIGIO)模型
特点:信号处理程序、发出信号通知用户程序发起系统调用、用户程序阻塞于发起的系统调用
信号通知用户程序 IO 操作什么时候开始。
异步I/O(Posix.1的aio_系列函数)模型
在整 IO 操作全部完成之后,告诉用户应用 I/O 操作已将完成,通知应用程序来读数据。
异步I/O是POSIX规范定义的。使用Proactor设计模式实现。
单线程(所有连接都使用主线程): recv 阻塞会导致其他链接全部阻塞,无法链接服务。
本质问题:阻塞,用户线程要等待内核系统调(实际IO操作)用的完成
【优化方案】
【示例代码地址:Java BIO 模型示例】
BIO 的几个优化方案虽然一定程度上提示了用户程序处理连接的能力,但是都有一个共同的问题:阻塞,每个连接都需要对应要给线程来处理,且处理时是阻塞的(等待准备时不知道什么时候准备好,一直傻傻的干等)。
在高并发(持续大量的连接同时请求)场景中,之前的两种 BIO 优化方案都需要消耗大量的线程来维持连接。并且 CPU 在线程切换上消耗很大。
Java NIO 模型的主要优势:少量的线程就可以处理大量连接的请求。
主要组成:
所用通道都向 Selector 注册,Selector 负责轮询检测,然后服务端进程会阻塞在 Selector 的 select() 方法,直到注册的通道有事件就绪。
【示例代码地址:Java NIO 模型代码示例】
前面的JavaIO方案都是同步的,用户线程需要等待内核的 IO 操作。
Java SE 7之后的版本,引入了对异步 I/O(NIO.2)的支持。
非阻塞IO模型,可以让用户线程知道什么时候可以开始和内核交互数据,即数据什么时候准备好
异步 IO 模型,可以让用户线程知道,IO操作什么时候完全结束(彻底完成)
一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC 假定一些其他协议的存在(如TCP/UDP,HTTP),以此在通信程序之间携带数据。
RPC 协议跨越网络模型的应用层和传输层。可以利用 Http 协议携带数据,也可以使用 TCP 协议,他们的共同特点是屏蔽网络层的细节,让远程过程调用像本地过程调用一样,不同之处在数据的组织形式、协议不同。
基于 HTTP 的优势是更灵活,基于 TCP 的优势是更高效。
RPC 调用步骤流程图:
RPC 框架将 步骤2~15 封装起来。
实现 RPC 需要考虑的问题:
常见的RPC框架:
REST 与 RPC:
REST 是一种架构风格,REST规范把所有内容都视为资源,网络上一切皆资源。RPC 时一种协议,一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。他们没有特殊的联系,本质都是网络交互的协议规范。
- 侧重点:
REST 的主题是资源,是面向资源的设计,RPC 侧重于动作(调用)- 传输效率上
RPC 可以使用传输层做数据交互,效率会更高- 复制度:
RPC 实现复杂,过程繁琐,而 REST 风格比较灵活简单
RPC 需要实现编码,序列化,网络传输等,而 RESTful 不要关注这些。应用场景:RPC 更适合服务内部调用、REST 更适合对外提共接口服务
问题:
当无法保证发出请求时接收端一定正在执行的情况下,RPC 的同步调用特性会造成客户端在发出的请求在得到处理之前就被阻塞了,因而有时也需要采取其他的办法。
例如:服务端的服务性能跟不上客户端发送的请求时,客户端的请求会被阻塞,这也会对服务端带来很大的压力。
面向消息的通信,提供一种异步的通信方式。一般由消息队列系统或面向消息的中间件提供高效可靠的消息传递机制。使得程序间没有直接的联系,所以它们不必同时运行。
常见的MQ或MOM产品:
Java面向消息中间件的API,用于两个或多个客户端之间发送消息。JMS是一系列的接口及相关语义的集合。
支持的消息模式:
接口源码包位置:javax.jms
JMS组成结构:
JMS message:消息头,消息体,消息属性
JMS的可靠性:利用 持久化/ACK确认机制/事务 来保证消息的可靠性