1、案例实战:微信抢红包以及购物分享短连接推广
1.1、案例实战:微信抢红包
1.1.1、需求分析
- 各种节假日,发红包+抢红包,不说了,100%高并发业务要求,不能用mysql来做
- 一个总的大红包,会有可能拆分成多个小红包,总金额=分金额1+分金额2+分金额3…分金额N
- 每个人只能抢一次,你需要有记录,比如100块钱,被拆分成10个红包发出去,总计有10个红包,抢一个少一个,总数显示(10/6)直到完,需要记录那些人抢到了红包,重复抢作弊不可以。
- 有可能还需要你计时,完整抢完,从发出到全部over,耗时多少?
- 红包过期,或者群主人品差,没人抢红包,原封不动退回。
- 红包过期,剩余金额可能需要回退到发红包主账户下。
由于是高并发不能用mysql来做,只能用redis,那需要要redis的什么数据类型?
1.1.2、数据类型分析
- 发红包,用list结构
- 抢红包,list.pop,进行抢数据
- 记(记录谁抢了多少+防止重复抢+如果红包到期没有抢完,需要回退) hash
- 红包算法-均值,保证每个红包大致有个范围,大家抢的差不多,使用二倍均值法。
二倍均值法
剩余红包金额为M,剩余人数为N,那么有如下公式:每次抢到的金额=随机区间(0,(剩余红包金额M÷剩余人数N)X2)
这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平。
举个栗子:
假设有10个人,红包总额100元。
- 第1次:100÷10X2=20,所以第一个人的随机范围是(O,20 ),平均可以抢到10元。假设第一个人随机到10元,那么剩余金额是100-10=90元。
- 第2次:90÷9 X2=20,所以第二个人的随机范围同样是(O,20),平均可以抢到10元。假设第二个人随机到10元,那么剩余金额是90-10=80元
- 第3次:80÷8 X2=20,所以第三个人的随机范围同样是(O,20 ),平均可以抢到10元。
- 以此类推,每一次随机范围的均值是相等的。
1.1.3、编码实操
package com.atguigu.redis.controller;
import cn.hutool.core.util.IdUtil;
import com.google.common.primitives.Ints;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@RestController
public class RedPackageController {
public static final String RED_PACKAGE_KEY = "redpackage:";
public static final String RED_PACKAGE_CONSUME_KEY = "redbackage:consume";
@Resource
private RedisTemplate redisTemplate;
@RequestMapping("/send")
public String sendRedPackage(int totalMoney, int redPackageNumber) {
//1拆红包,总金额拆分成多少个红包,每个小红包里面包多少钱
Integer[] splitRedPackages = splitRedPackage(totalMoney, redPackageNumber);
//2 红包的全局ID
String key = RED_PACKAGE_KEY + IdUtil.simpleUUID();
//3采用list存储红包并设置过期时间,红包主有且仅有一个,不用加锁控制
redisTemplate.opsForList().leftPushAll(key, splitRedPackages);
redisTemplate.expire(key, 1, TimeUnit.DAYS);
return key + "\t" + "\t" + Ints.asList(Arrays.stream(splitRedPackages).mapToInt(Integer::valueOf).toArray());
}
@RequestMapping("/rob")
public String rodRedPackage(String redPackageKey, String userId) {
//1验证某个用户是否抢过红包
Object redPackage = redisTemplate.opsForHash().get(RED_PACKAGE_CONSUME_KEY + redPackageKey, userId);
//2没有抢过就开抢,否则返回-2表示抢过
if (redPackage == null) {
//2.1 从list里面出队一个红包,抢到了一个
Object partRedPackage = redisTemplate.opsForList().leftPop(RED_PACKAGE_KEY + redPackageKey);
if (partRedPackage != null) {
//2.2 抢到手后,记录进去hash表示谁抢到了多少钱的某一个红包
redisTemplate.opsForHash().put(RED_PACKAGE_CONSUME_KEY + redPackageKey, userId, partRedPackage);
System.out.println("用户: " + userId + "\t 抢到多少钱红包: " + partRedPackage);
//TODO 后续异步进mysqL或者RabbitMQ进一步处理
//TODO 后续异步进mysqL或者RabbitMQ进一步处理
//TODO 后续异步边mysqL或者RabbitMQ进一步处理
return String.valueOf(partRedPackage);
}
//抢完
return "errorcode: -1,红包抢完了";
}
//3 某个用户抢过了,不可以作弊重新抢
return "errorcode:-2, message: " + "\t" + userId + "用户你已经抢过红包了";
}
private Integer[] splitRedPackage(int totalMoney, int redPackageNumber) {
//已经被抢的钱
int useMoney = 0;
//每次抢到的钱
Integer[] redPackageNumbers = new Integer[redPackageNumber];
Random random = new Random();
for (int i = 0; i < redPackageNumber; i++) {
if (i == redPackageNumber - 1) {
redPackageNumbers[i] = totalMoney - useMoney;
} else {
//每次抢到的金额=随机区间(0,(剩余红包金额÷未被抢的剩余红包个数 ) X 2)
int avgMoney = ((totalMoney - useMoney) / (redPackageNumber - i)) * 2;
//System.out.println( avgMoney ) ;
redPackageNumbers[i] = 1 + random.nextInt(avgMoney - 1);
}
useMoney = useMoney + redPackageNumbers[i];
}
return redPackageNumbers;
}
}
1.2、案例实战:B站视频、淘宝购物分享短连接推广
1.2.1、使用场景
-
B站视频
-
小米购物
-
淘宝购物分享
好处:
- 简单方便,利与推广
- http传输好了很多,有助于带宽节约和高并发
- 防止尾巴参数泄密,不安全
1.2.2、需求分析
- 短链接算法,新浪的算法
- 映射匹配,使用redis里面的hash,key是短链接地址,value是长连接地址
- 短-跳转-长的真实地址
1.2.3、代码实操
package com.song.redis.controller;
import org.apache.commons.codec.digest.DigestUtils;
public class ShortUrlUtils {
//26+26+10=62
public static final String[] chars = new String[]{"a", "b", " c", "d", "e", "f", "g", "h",
"i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
"u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H",
"I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"};
/**
* 一个长链接uRL转换为4个短KEY
* 思路:
* 1)将长网址md5生成32位签名串,分为4段,每段8个宇节;
* 2)对这四段循环处理,取8个字节,将他看成l6进制串与0x3ffffff(30位1)与操作,即超过30位的忽略处理;3)这30位分成6段,每5位的数字作为字母表的索引取得特定字符,依次进行获得6位字符串;
* 4 )总的md5串可以获得4个6位串;取里面的任意一个就可作为这个长urLl的短urL地址;
* 当我们点击这6个字母的链接后,我们又可以跳转到原始的真实长链接地址。
* <p>
* <p>
* https://www.bilibili.com/video/BV1Hy4y1B78T?p=1&medium=android&share_plat=android&share_source=COPY%3D333.999.0.0
*/
public static String[] shortUrl(String url) {
//对传入长网址进行MD5加密
String sMD5EncryptResult = DigestUtils.md5Hex(url);
System.out.println("---------------sMD5EncryptResult: “+sMD5EncryptResult);system.out.println()");
// md5处理后是32位
String hex = sMD5EncryptResult;
//切割为4组,每组8个字符,32 = 4 * 8
String[] resurl = new String[4];
for (int i = 0; i < 4; i++) {
//取出8位字符串,md5 32位,按照8位一组字符,被切割为4组
String sTempSubstring = hex.substring(i * 8, i * 8 + 8);
System.out.println("" + sTempSubstring);
//返回由指定基数中的字符串参数表示的Long型数
System.out.println("将sTempSubstring转换为1ong型:" + Long.parseLong(sTempSubstring, 16));
//把加密字符按照8位一组正进制与0x3FFFFFFF 进行位与运算
//16进制的0x3FFFFFFF等于二进制的00 111111111111111111111111111111,等于10进制的1073741823
// 这里需要使用Long 型来转换,因为 Integer.parseInt()只能处理31位,
//首位为符号位,如果不用Long ,则会越界,下面做&运算保证不越界。
// 00111110100000001110101000111000,等于10进制的1048635960
long lHexLong = 0x3FFFFFFF & Long.parseLong(sTempSubstring, 16);
System.out.println("---------1HexLong: " + lHexLong);
String outchars = "";
for (int j = 0; j < 6; j++) {
//0x0000003D它的10进制是61,61代表上面定义的chars数组长度62的0到61的坐标。
//ox0000003D & LHexLong进行位与运算,就是格式化为6位,即保证了index绝对是61以内的值
long index = 0x0000003D & lHexLong;
System.out.println("----------index: " + index);
//按照下标index把从chars数组取得的字符逐个相加
outchars += chars[(int) index];
//每次循环按位移5位,因为30位的二进制,分6次循环,即每次右移5位
lHexLong = lHexLong >> 5;
}
//把字符串存入对应索引的输出数组, 会产生一组6位字符串
resurl[i] = outchars;
}
return resurl;
}
public static void main(String[] args) {
//长连接
String longUrl = "https://www.bilibili.com/video/BV1Hy4y1B78T?p=1&medium=android&share_plat=android&share_source=COPY%3D333.999.0.0";
//转换成的短链接后5位码,返回4个短链接
String[] shortCodeArray = shortUrl(longUrl);
for (int i = 0; i < shortCodeArray.length; i++) {
//任意一个都可以作为短链接码
System.out.println(shortCodeArray[i]);
}
}
}
package com.song.redis.controller;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Random;
@RestController
public class ShortUrlController {
private final static String SHORT_URL_KEY = "short:url";
@Resource
private HttpServletResponse response;
@Resource
private RedisTemplate redisTemplate;
@GetMapping(value = "/shortUrl/encode")
public String encode(@RequestParam("longUr1") String longUrl) {
// Longurl = "https : / [ " + LongurL;
//一个长链接urL转换为4个短加密串key
String[] keys = ShortUrlUtils.shortUrl(longUrl);
//任意取出其中一个,我们就拿第一个
String shortUrlKey = keys[new Random().nextInt(4)];
//用hash存储,key=加密串,value=原始urL
this.redisTemplate.opsForHash().put(SHORT_URL_KEY, shortUrlKey, longUrl);
System.out.println("长链接: " + longUrl + "\t" + "转换短链接: " + shortUrlKey);
return "http://127.6.3.1:5555/" + shortUrlKey;
}
@GetMapping(value = "/shortUrl/decode/ishortUrlKey}")
public void decode(@PathVariable String shortUrlKey) {
// 到redis中把原始urL找出来
String url = (String) this.redisTemplate.opsForHash().get(SHORT_URL_KEY, shortUrlKey);
System.out.println("----准备调整到真实长地址ur1(注意是否有前缀: https://): " + url);
try {
//重定向到原始的urL
response.sendRedirect(url);
} catch (IOException e) {
e.printStackTrace();
}
}
}
2、Redis为什么快以及高性能设计之epoll和IO多路复用深度解析
2.1、Redis单线程如何处理那么多并发客户端连接,为什么单线程,为什么快
Redis利用linux的底层系统函数epoll来实现IO多路复用,当有多个请求过来的时候,epoll函数会将连接信息和事件放到事件队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
io多路复用是一种思想,天上飞的理念,必须有落地的实现,Linux系统内核提供了一种底层函数select-poll-epoll来实现,redis单线程模式,多lO的访问
Redis是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以IO操作在一般情况下往往不能直接返回,这会导致某一文件的I/O阻塞导致整个进程无法对其它客户提供服务,而IO多路复用就是为了解决这个问题而出现。
所谓IO多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作,这种机制的使用需要select、poll 、epoll来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis服务采用Reactor的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符),Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。
因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。
2.2、IO多路复用
2.2.1、IO多路复用是什么:
- l/O :网络I/O
- 多路:多个客户端连接(连接就是套接字描述符,即socket或者channel)
- 复用:复用一个或几个线程。也就是说一个或一组线程处理多个TCP连接,使用单进程就能够实现同时处理多个客户端的连接
一句话:一个服务端进程可以同时处理多个套接字描述符,其发展可以分select->poll->epoll三个阶段来描述。
2.2.2、概念术语
2.2.2.1、同步和异步
- 同步:调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止
- 异步:指被调用方先返回应答让调用者先回去,然后再计算调用结果,计算完最终结果后再通知并返回给调用方,异步调用要想获得结果一般通过回调
- 同步与异步的理解:同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式上
2.2.2.2、阻塞和非阻塞
- 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程会被挂起,啥都不干
- 非阻塞:调用在发出去后,调用方先去忙别的事情,不会阻塞当前进/线程,而会立即返回
- 阻塞与非阻塞的理解:阻塞、非阻塞的讨论对象是调用者(服务请求者)重点在于等消息时候的行为,调用者是否能干其它事
2.2.2.1、同步和阻塞之间的关系
- 同步阻塞:在需要某资源时马上发起请求,并暂停本线程之后的程序,直至获得所需的资源。例如在海底捞火锅,服务员说快到你了,先别离开我后台看一眼马上通知你。客户在海底捞火锅前台干等着,啥都不干。
- 同步非阻塞:在需要某资源时马上发起请求,且可以马上得到答复,然后继续执行自己之后的程序,之后将周期性地的请求,直至获取资源。。例如在海底捞火锅,服务员说快到你了,先别离开,客户在海底捞火锅前台边刷抖音边等着叫号,如果一直没有叫到,就会隔一段时间问一次。
- 异步阻塞:在需要某资源时马上发起请求,且可以马上得到答复,但是暂停本线程之后的程序,直至获取资源。服务员说还要再等等你先去逛逛,一会儿通知你。客户怕过号在海底捞火锅前含拿着排号小票啥都不干,一直等着店员通知
- 异步非阻塞:在需要某资源时马上发起请求,且可以马上得到答复,然后继续执行自己之后的程序,之后回调,获取资源。服 务员说还要再等等,你先去逛逛,一会儿通知你。拿着排号小票+刷着抖音,等着店员通知
2.2.3、常见的5种IO模型
- BlockingIO-阻塞IO
- NoneBlocking IO -非阻塞IO
- lOmultiplexing - IO多路复用
- signal driven lO-信号驱动lO
- asynchronous lO-异步IO
2.2.3.1、BlockingIO-阻塞IO
2.2.3.1.1、 BIO原理以及执行流程
当用户进程调用了recvfrom函数,kemnel(内核)就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来〉。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kemnel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。所以,BIO的特点就是在IO执行的两个阶段都被block 了。
2.2.3.1.2、 BIO有两个阻塞的地方
BIO有两个阻塞的地方,
- 第一个是当有一个客户端连接成功之后,就会在accept()进行阻塞,第二个客户端连接的时候就会进行阻塞,只有第一个客户端断开连接之后,第二个才能进行连接。
- 第二个是当客户端发送数据的时候,服务端就会读取,不发送数据,就会一直阻塞read()。
2.2.3.1.3、 BIO存在的问题
上面的模型存在很大的问题,如果客户端与服务端建立了连接,如果这个连接的客户端迟迟不发数据,程就会一直堵塞在read()方法上,这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好。
2.2.3.1.3、 BIO问题的解决方式
- 利用多线程
原理:只要连接了一个socket,操作系统分配一个线程来处理,这样read()方法堵塞在每个具体线程上而不堵塞主线程,就能操作多个socket了,哪个线程中的socket有数据,就读哪个socket,各取所需,灵活统一。
- 程序服务端只负责监听是否有客户端连接,使用accept()阻塞
- 客户端1连接服务端,就开辟一个线程(thread1)来执行read()方法,程序服务端继续监听
- 客户端2连接服务端,也开辟一个线程(thread2〉来执行read()方法,程序服务端继续监听
- 客户端3连接服务端,也开辟一个线程(thread3)来执行read()方法,程序服务端继续监听。。 。 。
- 任何一个线程上的socket有数据发送过来,read()就能立马读到,cpu就能进行处理。
-
多线程模型存在的问题
每来一个客户端,就要开辟一个线程,如果来1万个客户端,那就要开辟1万个线程。在操作系统中用户态不能直接开辟线程,需要调用内核来创建的一个线程,这其中还涉及到用户状态的切换(上下文的切换),十分耗资源。 -
多线程模型问题的解决方式
- 第一个办法:使用线程池
这个在客户端连接少的情况下可以使用,但是用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行。 - 第二个办法:NIO(非阻塞式IO)方式
因为read()方法堵塞了,所有要开辟多个线程,如果什么方法能使read()方法不堵塞,这样就不用开辟多个线程了,这就用到了另一个IO模型,NIO(非阻塞式IO),tomcat7乏前就是用BIO多线程来解决多连接
2.2.3.2、NoneBlocking IO -非阻塞IO
2.2.3.2.1、 NIO原理以及执行流程
当用户进程发出read操作时,如果kernel(内核)中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个eror。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个errorl时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。所以,NIO特点是用户进程需要不断的主动询问内核数据准备好了吗?
在非阻塞式IO模型中,应用程序把一个套接口设置为非阻塞,就是告诉内核,当所请求的IO操作无法完成时,不要将进程睡眠,而是返回一个“错误”,应用程序基于IO操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。
在NIO模式中,一切都是非阻塞的,accept()方法是非阻塞的,如果没有客户端连接,就返回error,read()方法是非阻塞的,如果read()方法读取不到数据就返回error,如果读取到数据时只阻塞read()方法读数据的时间。所以在NIO模式中,只有一个线程,当一个客户端与服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次,看这个socket的read()方法能否读到数据,这样一个线程就能处理多个客户端的连接和读取了。
2.2.3.2.2、 NIO存在的问题和优缺点
NIO成功的解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,但是还存在2个问题。
-
问题一:
这个模型在客户端少的时候十分好用,但是客户端如果很多,比如有1万个客户端进行连接,那么每次循环就要遍历1万个socket,如果一万个socket中只有10个socket有数据,也会遍历一万个socket,就会做很多无用功,每次遍历遇到read返回-1时仍然是一次浪费资源的系统调用。 -
问题二:
而且这个遍历过程是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销很大因为这些问题的存在。 -
优缺点
优点:不会阻塞在内核的等待数据过程,每次发起的IO请求可以立即返回,不用阻塞等待,实时性较好。
缺点:轮询将会不断地询问内核,这将占用大量的CPU时间,系统资源利用率较低,所以一般Web服务器不使用这种IO模型。
2.2.3.2.2、 结论
让循环遍历所有的连接数组,让read操作读取数据,判断是否存在数据也是需要调用内核的方法进行来进行的,同样需要用户态和内核态之间进行切换的,所以需要将这一操作交给内核层去处理,不用用户态和内核态之间转换,而是直接从内核获得结果,因为内核是非阻塞的,这也是IO多路复用出现的原因。
2.2.3.3、lOmultiplexing - IO多路复用
2.2.3.3.1、IO多路复用原理及执行流程
l0 multiplexing就是我们说的select,poll,epoll有些地方也称这种IO方式为event driven lO事件驱动O。
就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪〈一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程),这样可以大大节省系统资源。
所以,IO多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
2.2.3.3.2、 5种lO模型总结
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的while循环里多次系统调用,变成了一次系统调用+内核层遍历这些文件描述符。
所谓IO多路复用机制,就是说通过一种机制,可以监视多个描述符(内核为该进程记录打开文件维护了一个记录表,记录表里的每一索引值,对应了一个打开的文件),一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要select、 poll、epll来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理;
I/O多路复用在英文中其实叫IO multiplexing
IO multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个IlO流.目的是尽量多的提高服务器的吞吐能力。
大家都用过nginx,nginx使用epoll接收请求,ngnix会有很多链接进来,epoll会把他们都监视起来.然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis类似同理
2.2.3.3.3 、 FileDescriptor
这一块的描述是来自:做1个快乐的程序员
一个进程是可以可以打开多个文件的,无非就是多调用几次open,而我们的计算机中是同时存在大量进程的,而这些进程可能会打开各种各样的文件,所以系统中在任何时刻都可能存在大量已经打开的文件,操作系统的功能之一就是文件管理,就是要对这些打开的文件进行管理。
而我们都知道,所谓管理就是先描述再管理,底层中描述文件的数据结构叫做struce file,一个文件对应一个struct file,大量的文件就有大量的struct file,我们只需将这些数据结构用双链表连接起来,所以对文件的管理就变成了对双链表的增删改查。而我们现在要做的,这些已经被打开的文件那些文件属于某个特定的进程,就需要建立进程和文件的对应关系。
每一个进程都有一个task_strut,这个task_struct会指向一个struct files_struct结构体,这个结构体里会有一个指针数组struct file* fd_array[32],而这个指针数组就是文件描述符对应的数组。
既然是数组就有下标,下标从0开始依次递增,task_struct结构里会有一个指针变量指向这个struct files_struct结构体,我们这个指针数组中的每个数据都是一个指针变量。默认的3个文件+我们上面的例子中自己打开的log.txt,总共有4个文件描述符打开,这四个打开的文件描述符都对应一个struct file的结构体,结构体里有描述该文件属性的相关信息,而这些struct file文件结构体之间,是通过双链表的形式链接起来的。
对于输入输出错误,将下标012分配给他们,自己打开的文件从3开始依次分配,当我们将下标和struct file结构体的指向关系表明清楚以后,open函数返回时,就会将下标数字直接返回给调用方,至此在应用层我们就拿到了文件描述符,至此我们就完成了文件和进程的对应关系。所以,所谓的文件描述符实际就是数组的下标。
Linux 中所有内容都是以文件的形式保存和管理的,即一切皆文件,普通文件是文件,目录(Windows 下称为文件夹)是文件,硬件设备(键盘、监视器、硬盘、打印机)是文件,就连套接字(socket)、网络通信等资源也都是文件,linux系统中一切皆文件。
每一个文件描述符会与一个打开文件相对应,同时,不同的文件描述符也会指向同一个文件。相同的文件可以被不同的进程打开也可以在同一个进程中被多次打开。系统为每一个进程维护了一个文件描述符表,该表的值都是从0开始的。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程,所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符,在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
内核(kernel)利用文件描述符(file descriptor)来访问文件。打开现存文件或新建文件时,内核会返回一个文件描述符,读写文件也需要使用文件描述符来指定待读写的文件。
情景类比:模拟一个tcp服务器处理30个客户socket。
假设你是一个监考老师,让30个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:
第一种选择。按顺序逐个验收,先验收A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理socket,根本不具有并发能力。这种就是BIO
第二种选择:你创建30个分身线程,每个分身线程检查一个学生的答案是否正确。这种类似于为每一个用户创建一个进程或者线程处理连接。这种就是NIO
第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。这种就是IO复用模型。
Linux下的select、poll和epoll就是干这个的。
将用户socket对应的fd(类似一个唯一标识)注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式。
2.2.3.3.4、Reactor模式
基于IO复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Reactor模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor模式也叫Dispatcher模式。
即IO多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术。
- Reactor模式中有2个关键组成:
- Reactor:Reactor在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对I0事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
- Handlers:处理程序执行IIO事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际办理人。Reactor通过调度适当的处理程序来响O车件.外理程序执行非阳寒操作
2.2.3.4、select, poll, epoll函数
select, poll, epoll函数,都是IO多路复用的具体的实现。
所谓IO多路复用机制指内核一旦发现进程指定的一个或者多个lO条件准备读取,它就通知该进程,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要select、poll、epoll来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
2.2.3.4.1、select
linux 操作系统查看方式:man select
分析select函数的执行流程:
- select是—个阻塞函数,当没有数据时,会—直阻塞在select那一行。
- 当有数据时会将rset中对应的那一位置为1
- select函数返回,不再阻塞
- 遍历文件描述符数组,判断哪个fd被置位了
- 读取数据,然后处理
select函数的作用,其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那予)拷贝到了内核态,让内核态来遍历,因为在用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了。
select函数具有的问题
- bitmap最大1024位,一个进程最多只能处理1024个客户端
- &rset不可重用,每次socket有数据就相应的位会被置位。&rset是个bitmap
- 文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入fd数组,需要拷贝一份到内核,高 并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
select小结论:select方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次select的系统调用+N次就绪状态的文件描述符的read系统调用)
2.2.3.4.2、poll
poll的执行流程:
- 将五个fd从用户态拷贝到内核态
- poll为阻塞方法,执行poll方法,如果有数据会将fd对应的revents置为POLLIN
- poll方法返回
- 循环遍历,查找哪个fd被置位为POLLIN了5.将revents重置为0便于复用
- 对置位的fd进行读取和处理
解决的问题
- poll使用polfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和 select的主要区别就是,去掉了 seled只能监听1024个文件描述符的限制。
- 当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用
poll解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题
- pollfds数组拷贝到了内核态,仍然有开销
- poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历
2.2.3.4.2、epoll
-
epoll_create:创建个epoll句柄,int epoll_create(int size);
-
epoll_ctl:向内核添加、修改或删除要监控的文件描述符,int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epoll_wait:类似发起了select()调用,int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
int epoll_create(int size): 参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
见上图
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout):等待epfd上的io事件,最多返回maxevents个事件。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大
epoll是非阻塞的!!
epoll的执行流程:
- 当有数据的时候,会把相应的文件描述符“置位”,但是epool没有revent标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首。
- epoll会返回有数据的文件描述符的个数
- 根据返回的个数读取前N个文件描述符即可
- 读取、处理
事件通知机制
- 当有网卡上有数豳到达了,首先会放到DMA(内存中的一个buffer,网卡可以直接访问这个数据区域)中
- 网卡向cpu发起中断,让cpu先处理网卡的事
- 中断号在内存中会绑定一个回调,哪个socket中有数据,回调函数就把哪个socket放入就绪链表中
结论
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while循环里多次系统调用,变成了一次系统调用+内核层遍历这些文件描述符。
epoll是现在最先进的IO多路复用器,Redis、Nginx,linux中的Java NIO都使用的是epoll这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。
- 一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小
- 使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket连接
在多路复用IO模型中,会有一个内核线程不断地去轮询多个socket的状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作。
因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用。
多路I/O复用模型是利用select、poll、epoll可以同时监察多个流的IO事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有IO事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
采用多路IO复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈。
3、总结:
Redis 是一款高性能的 key-value 存储系统,它采用 IO 多路复用技术来实现高并发和高吞吐量的数据访问。IO 多路复用指的是通过一条线程来处理多个网络连接上的 IO 操作,从而避免了创建多个线程的开销和线程上下文切换的开销。
Redis 的 IO 多路复用是通过 select、poll 或 epoll 等系统调用实现的。Redis 会将需要监听的 socket 描述符添加到事件监听集合中,当有数据到达时,内核会通知 Redis,Redis 再进行处理。Redis 的事件处理器会维护一个事件表,这个表包含了 Redis 可以处理的所有事件和相应的处理函数。当 Redis 从内核中接收到一个事件时,它会根据事件的类型找到相应的处理函数进行处理。
Redis 的事件处理器采用 Reactor 模式来处理客户端连接和命令请求。在 Reactor 模式中,事件处理器会同时监听多个客户端连接,一旦有连接上来,就会将该连接上的事件进行分发,并使用多线程或者事件驱动等方式并发处理多个事件,从而提高并发能力。
在 Redis 中,客户端发送的命令和服务器的响应都是通过 socket 进行传输的。通过 IO 多路复用技术,Redis 可以同时处理多个连接的数据传输,从而提高了系统的并发性能。同时,Redis 也采用了异步非阻塞的 IO 模型,能够处理大量的并发连接,提高了系统的吞吐量和响应速度。