• [计算机网络]--五种IO模型和select


    前言

    作者:小蜗牛向前冲

    名言:我可以接受失败,但我不能接受放弃

      如果觉的博主的文章还不错的话,还请点赞,收藏,关注👀支持博主。如果发现有问题的地方欢迎❀大家在评论区指正 

    目录

    一、五种IO模型

    1、什么是IO

    2、感性的理解五种IO模型

    3、理解五种IO模型

    4、高级IO重要概念

     二、I/O多路转接之select

    1、select的基本概念和接口介绍

    2、对select的理解 

    三、select服务器的编写

    1、err.hpp和log.hpp

    2、makefile和main.cc 

    3、 selectServer.hpp和sock.hpp

    4、测试 


     本期学习:IO五层模型的理解,select的接口常识及其多路转接的理解,编写select服务器

    一、五种IO模型

    1、什么是IO

    "IO" 通常指的是输入/输出(Input/Output)。

    • 在计算机科学和编程中,输入/输出是指程序与外部世界、外部设备或其他程序之间进行数据交换的过程。
    • 这些外部设备可以包括磁盘驱动器、网络连接、键盘、鼠标、显示器等。输入是指程序接收来自外部环境的数据,输出是指程序将数据发送到外部环境。

     IO的操作

    • 从文件中读取数据、向文件写入数据、从网络接收数据、向网络发送数据,以及与硬件设备进行交互等。
    • IO 操作通常是相对较慢的,因为它们涉及到与外部设备或网络通信,而这些通信可能涉及到物理设备的限制或网络延迟。 

    本文主要讨论文件上的IO。

    我们在文件上写入或者是读取数据,在系统层面上就是调用read/recv这些函数借口,前面我们也谈论过调用这些函数的本质其实在拷贝数据。

    对于read/recv无非存在二种情况:

    • 没有数据,就会进行阻塞等待。
    • 有数据就会进行拷贝,完成后返回。

    这也就说明IO的本质是拷贝+等待 

    那我们如何做到高效IO呢?

    本质上我们只要减少等待的时间就可以。

    下面我们通过一个故事感性的理解五种IO模型。

    2、感性的理解五种IO模型

    有这么几个人,他们非常喜欢钓鱼。

    1号张三用一根钓鱼竿钓鱼,他喜欢一直盯这鱼竿看鱼有没有上钩。

    2号李四也是一根钓鱼竿钓鱼,但是他就比较休闲,他是每隔一定时间看一下鱼竿动了没,没动就去做别的事情。

    3号王五也是一根钓鱼竿钓鱼,但他就比较有意思,他在鱼竿上寄了一个铃铛,要是鱼竿动了他就拉杆看有没鱼,没声音响就一直忙自己的时候。

    4号赵六他觉的用一根鱼竿钓鱼的效率太慢了,于是就弄了一排鱼竿,来会的在这一排鱼竿旁边走,看那个鱼竿动了就拉起来。

    5号小王他是个大老板,他喜欢吃这里钓的鱼吃,自己时间又忙,于是他就让他的属下田七来这里钓鱼。


    在上面故事中的钓鱼其实就分为等+钓。

    那上面谁钓鱼的效率高呢?我们知道等的比重越低,单位时间内钓鱼的越高。

     那肯定是赵六的效率是最高的,因为他等的比例是最低的。

    在程序员看来我们可以认为:

    鱼就是数据,鱼塘就是内核空间,鱼竿发生动作鱼就绪是数据就绪的事情,鱼竿我们就认为是文件描述符,钓鱼的动作:recv/read系统接口的调用。

     五号任务就代表五种IO模型:

    张三----------->阻塞式IO

    李四----------->非阻塞式IO

    王五----------->信号驱动式IO

    赵六----------->多路转接/多路复用

    田七----------->异步IO(2这里的老板赵六相当鱼操作系统,田七相当进程/线程)

    3、理解五种IO模型

    阻塞IO是最常见的IO模型

    在阻塞 I/O 中,当应用程序发起一个 I/O 操作(比如读取文件或者从网络接收数据),程序会被阻塞(暂停执行),直到操作完成并且数据准备好被应用程序处理。

     

    非阻塞IO 

    非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码 

    非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一 般只有特定场景下才使用. 

      信号驱动IO

     信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

    IO多路转接:  

    IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件 描述符的就绪状态 

     ​

     小结

    • 任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝.
    • 而且在实际的应用场景中, 等待消耗的时间往 往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少

    4、高级IO重要概念

    同步通信 vs 异步通信

    • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
    • 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步 过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用 者,或通过回调函数处理这个调用

    另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥. 这里的同步通信和进程之间的同步是完全不想干的概 念. 

    • 进程/线程同步也是进程/线程之间直接的制约关系
    • 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、 传递信息所产生的制约关系. 尤其是在访问临界资源的时候

     同学们以后在看到 "同步" 这个词, 一定要先搞清楚大背景是什么. 这个同步, 是同步通信异步通信的同步, 还是进程同步与互斥的同步

    阻塞 vs 非阻塞

    阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态

    • 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
    • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

     二、I/O多路转接之select

    1、select的基本概念和接口介绍

    这里我们先一起达成一个公识:IO的本质=等+拷贝。

    select是一个系统调用只负责等,可以等待多个fd,select本身没有数据拷贝的能力,拷贝还是要read,write来完成。

    系统提供select函数来实现多路复用输入/输出模型.

    • select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
    • 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;

    select函数原型

    包含的头文件

    1. #include
    2. #include
    3. #include
    1. int select(int nfds, fd_set *readfds, fd_set *writefds,
    2. fd_set *exceptfds, struct timeval *timeout);

    参数说明 

    • nfds:监视的文件描述符中最大的文件描述符值加一。
    • readfds:指向一个 fd_set 结构的指针,用于指定一组待检查是否可读的文件描述符。
    • writefds:指向一个 fd_set 结构的指针,用于指定一组待检查是否可写的文件描述符
    • exceptfds:指向一个 fd_set 结构的指针,用于指定一组待检查是否异常的文件描述符。
    • timeout:指向 struct timeval 结构的指针,用于设置 select() 调用的超时时间,如果为 NULL 则表示不设置超时,会一直阻塞直到有文件描述符就绪或者被信号中断。

    参数timeout取值

    • NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
    • 0:非阻塞仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
    • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。
    • struct timeval timeout{5,0}:表示5秒以内阻塞,超过5秒,非阻塞返回一次。

    返回值 

    • ret>0 告诉系统育多少个fd就绪
    • ret==0调用超时,返回
    • ret<0调用失败

    fd_set:位图结构,表示文件描述符的集合 

    • 其实这个结构就是一个整数数组, 更严格的说, 是一个 "位图". 使用位图中对应的位来表示要监视的文件描述符

     提供了一组操作fd_set的接口, 来比较方便的操作位图.

    • void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
    • int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
    • void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
    • void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

    关于timeval结构 

    timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0

    函数返回值: 

    • 执行成功则返回文件描述词状态已改变的个数
    •  如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
    • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的 值变成不可预测。

    错误值可能为:

    • EBADF (ebadf)文件描述词为无效的或该文件已关闭
    • EINTR(eintr) 此调用被信号所中断
    • EINVAL(einval) 参数n 为负值。
    • ENOMEM(enomem) 核心内存不足

    2、对select的理解 

    理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描 述符fd。则1字节长的fd_set最大可以对应8个fd

    • (1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。
    • (2)若fd=5,执行FD_SET(fd,&set); 后set变为0001,0000(第5位置为1) 
    • (3)若再加入fd=2,fd=1,则set变为0001,0011 。
    • (4)执行 select(6,&set,0,0,0)阻塞等待 。
    • (5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为 0000,0011。注意:没有事件发生的fd=5被清空。

     socket就绪条件

    读就绪

    • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
    • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
    • 监听的socket上有新的连接请求;
    • socket上有未处理的错误;

    写就绪 

    • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记 SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
    • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE 信号;
    • socket使用非阻塞connect连接成功或失败之后;
    • socket上有未读取的错误;

    select的特点 

    • 可监控的文件描述符个数取决与sizeof(fd_set)的值. 我这边服务器上sizeof(fd_set)=512,每bit表示一个文件 描述符,则我服务器上支持的最大文件描述符是512*8=4096. 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd, 一是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。
    • 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得 fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

    select缺点 

    • 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便.
    • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
    • select支持的文件描述符数量太小

     select是如何实现多路转接的

    1. 准备监视的文件描述符集合:程序通过向内核传递一个文件描述符集合,告诉内核它希望监视哪些文件描述符的状态变化。

    2. 调用 select 系统调用:程序调用 select 系统调用,并将准备好进行 I/O 操作的文件描述符集合传递给内核。

    3. 内核监视文件描述符状态变化:内核开始监视这些文件描述符的状态变化。如果其中任何一个文件描述符的状态发生变化(例如,变为可读、可写或出现异常),内核将返回给程序。

    4. 程序处理返回结果:程序从 select 返回的结果中获取到哪些文件描述符准备好进行 I/O 操作,然后针对这些文件描述符执行相应的 I/O 操作。通常,程序会使用 readwrite 等系统调用来实际进行 I/O 操作。

     select 的实现通常使用轮询技术,内核会遍历程序提供的所有文件描述符,检查它们的状态是否发生变化。这种方式虽然简单,但效率较低,尤其在文件描述符数量较多时会导致性能下降。

    上面我们理解select进行多路转接的原理,下面我们自己写一个select多路转接的服务器加深理解。 

    三、select服务器的编写

    1、err.hpp和log.hpp

    err.hpp

    1. #pragma once
    2. #include
    3. enum
    4. {
    5. USAGE_ERR = 1, // usage_err
    6. SOCKET_ERR, // socket_err
    7. BIND_ERR, // bind_err
    8. LISTEN_ERR // listen_err
    9. };

    log.hpp

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. // debug
    8. #define DEBUG 0
    9. // normal
    10. #define NORMAL 1
    11. // warning
    12. #define WARNING 2
    13. // error
    14. #define ERROR 3
    15. // fatal
    16. #define FATAL 4
    17. const char *to_levelstr(int level)
    18. {
    19. switch (level)
    20. {
    21. case DEBUG:
    22. return "DEBUG";
    23. case NORMAL:
    24. return "NORMAL";
    25. case WARNING:
    26. return "WARNING";
    27. case ERROR:
    28. return "ERROR";
    29. case FATAL:
    30. return "FATAL";
    31. default:
    32. return nullptr;
    33. }
    34. }
    35. void logMessage(int level, const char *format, ...)
    36. {
    37. #define NUM 1024
    38. char logprefix[NUM];
    39. snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
    40. to_levelstr(level), (long int)time(nullptr), getpid());
    41. char logcontent[NUM];
    42. va_list arg;
    43. va_start(arg, format);
    44. vsnprintf(logcontent, sizeof(logcontent), format, arg);
    45. std::cout << logprefix << logcontent << std::endl;
    46. }

    2、makefile和main.cc 

    makefile

    1. select_server: main.cc
    2. g++ -o $@ $^ -std=c++11
    3. .PHONY:clean
    4. clean:
    5. rm -f select_server

    main.cc  

    1. #include "selectServer.hpp"
    2. #include "err.hpp"
    3. #include
    4. using namespace std;
    5. using namespace select_ns;
    6. static void usage(std::string proc)
    7. {
    8. std::cerr << "Usage:\n\t" << proc << " port"
    9. << "\n\n";
    10. }
    11. std::string transaction(const std::string &request)
    12. {
    13. return request;
    14. }
    15. // ./select_server 8081
    16. int main(int argc, char *argv[])
    17. {
    18. // if(argc != 2)
    19. // {
    20. // usage(argv[0]);
    21. // exit(USAGE_ERR);
    22. // }
    23. // unique_ptr svr(new SelectServer(atoi(argv[1])));
    24. // std::cout << "test: " << sizeof(fd_set) * 8 << std::endl;
    25. unique_ptr svr(new SelectServer(transaction));
    26. svr->initServer();
    27. svr->start();
    28. return 0;
    29. }

    3、 selectServer.hpp和sock.hpp

     selectServer.hpp

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include "sock.hpp"
    6. namespace select_ns
    7. {
    8. static const int defaultport = 8081;
    9. static const int fdnum = sizeof(fd_set) * 8;
    10. static const int defaultfd = -1;
    11. using func_t = std::functionstring(const std::string &)>;
    12. class SelectServer
    13. {
    14. public:
    15. SelectServer(func_t f, int port = defaultport) : func(f), _port(port), _listensock(-1), fdarray(nullptr)
    16. {
    17. }
    18. void initServer()
    19. {
    20. _listensock = Sock::Socket();
    21. Sock::Bind(_listensock, _port);
    22. Sock::Listen(_listensock);
    23. fdarray = new int[fdnum];
    24. for (int i = 0; i < fdnum; i++)
    25. {
    26. fdarray[i] = defaultfd;
    27. }
    28. fdarray[0] = _listensock;
    29. }
    30. void Print()
    31. {
    32. std::cout << "fd list: ";
    33. for (int i = 0; i < fdnum; i++)
    34. {
    35. if (fdarray[i] != defaultfd)
    36. std::cout << fdarray[i] << " ";
    37. }
    38. std::cout << std::endl;
    39. }
    40. void Accepter(int listensock)
    41. {
    42. logMessage(DEBUG, "Accepter in");
    43. // select 告诉我, listensock读事件就绪了
    44. std::string clientip;
    45. uint16_t clientport = 0;
    46. int sock = Sock::Accept(listensock, &clientip, &clientport);
    47. if (sock < 0)
    48. return;
    49. logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);
    50. // sock我们能直接recv/read 吗?不能,整个代码,只有select有资格检测事件是否就绪
    51. // 将新的sock 托管给select!
    52. // 将新的sock托管给select的本质,其实就是将sock,添加到fdarray数组中即可!
    53. int i = 0;
    54. // 找fdarray字符集中没有被占用的位置
    55. for (; i < fdnum; i++)
    56. {
    57. if (fdarray[i] != defaultfd)
    58. continue;
    59. else
    60. break;
    61. }
    62. if (i == fdnum)
    63. {
    64. logMessage(WARNING, "server if full, please wait");
    65. close(sock);
    66. }
    67. else
    68. {
    69. fdarray[i] = sock;
    70. }
    71. Print();
    72. logMessage(DEBUG, "Accepter out");
    73. }
    74. void Recver(int sock, int pos)
    75. {
    76. logMessage(DEBUG, "in Recver");
    77. // 1. 读取request
    78. // 这样读取是有问题的!
    79. char buffer[1024];
    80. ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
    81. if (s > 0)
    82. {
    83. buffer[s] = 0;
    84. logMessage(NORMAL, "client# %s", buffer);
    85. }
    86. else if (s == 0)
    87. {
    88. close(sock);
    89. fdarray[pos] = defaultfd;
    90. logMessage(NORMAL, "client quit");
    91. return;
    92. }
    93. else
    94. {
    95. close(sock);
    96. fdarray[pos] = defaultfd;
    97. logMessage(ERROR, "client quit: %s", strerror(errno));
    98. return;
    99. }
    100. // 2、处理request
    101. std::string response = func(buffer);
    102. // 3、返回response
    103. write(sock, response.c_str(), response.size());
    104. logMessage(DEBUG, "out Recver");
    105. }
    106. // 1. handler event rfds 中,不仅仅是有一个fd是就绪的,可能存在多个
    107. // 2. 我们的select目前只处理了read事件
    108. void HandlerReadEvent(fd_set &rfds)
    109. {
    110. // 遍历fdarray数组
    111. for (int i = 0; i < fdnum; i++)
    112. {
    113. // 过滤掉非法的fd
    114. if (fdarray[i] == defaultfd)
    115. continue;
    116. // 正常的fd,不一定就绪了
    117. if (FD_ISSET(fdarray[i], &rfds) && fdarray[i] == _listensock)
    118. Accepter(_listensock);
    119. else if (FD_ISSET(fdarray[i], &rfds))
    120. Recver(fdarray[i], i);
    121. else
    122. {
    123. }
    124. }
    125. }
    126. void start()
    127. {
    128. for (;;)
    129. {
    130. fd_set rfds;
    131. FD_ZERO(&rfds);
    132. int maxfd = fdarray[0];
    133. for (int i = 0; i < fdnum; i++)
    134. {
    135. if (fdarray[i] == defaultfd)
    136. continue;
    137. FD_SET(fdarray[i], &rfds); // 将合法的fd全部添加到读文件描述符中
    138. // 更新最大的maxfd
    139. if (maxfd < fdarray[i])
    140. maxfd = fdarray[i];
    141. }
    142. logMessage(NORMAL, "max fd is: %d", maxfd);
    143. int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
    144. switch (n)
    145. {
    146. case 0:
    147. logMessage(NORMAL, "timeout...");
    148. break;
    149. case -1:
    150. logMessage(WARNING, "select error, code: %d, err string: %s", errno, strerror(errno));
    151. break;
    152. default:
    153. // 说明有事件就绪了,目前只有一个监听事件就绪了
    154. logMessage(NORMAL, "have event ready!");
    155. HandlerReadEvent(rfds);
    156. // HandlerWriteEvent(wfds);
    157. break;
    158. }
    159. }
    160. }
    161. ~SelectServer()
    162. {
    163. if (_listensock < 0)
    164. close(_listensock);
    165. if (fdarray)
    166. delete fdarray;
    167. }
    168. private:
    169. int _port;
    170. int _listensock;
    171. int *fdarray;
    172. func_t func;
    173. };
    174. }

    sock.hpp 

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. #include "log.hpp"
    11. #include "err.hpp"
    12. class Sock
    13. {
    14. const static int backlog = 32; // sokc listen的数量
    15. public:
    16. static int Socket()
    17. {
    18. // 1创建套接字
    19. // int sock = socket(AF_FILE, SOCK_STREAM, 0); // af_file,sock_stream errror
    20. int sock = socket(AF_INET, SOCK_STREAM, 0);//af_inet
    21. if (sock < 0)
    22. {
    23. logMessage(FATAL, "create socket error");
    24. exit(SOCKET_ERR);
    25. }
    26. logMessage(NORMAL, "create socket success: %d", sock);
    27. int opt = 1;
    28. setsockopt(sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // sol_socket,so_reuseaddr,so_reuseport//服务器重启后可快速复用地址和端口
    29. return sock;
    30. }
    31. static void Bind(int sock, int port)
    32. {
    33. // 2bind绑定网络信息
    34. struct sockaddr_in local;
    35. memset(&local, 0, sizeof(local));
    36. local.sin_family = AF_INET; // afinet
    37. local.sin_port = htons(port);
    38. local.sin_addr.s_addr = INADDR_ANY; // inaddr_any
    39. if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
    40. {
    41. logMessage(FATAL, "bind socket error");
    42. exit(BIND_ERR);
    43. }
    44. logMessage(NORMAL, "bind socket success");
    45. }
    46. static void Listen(int sock)
    47. {
    48. // 3设置sock为监听
    49. if (listen(sock, backlog) < 0)
    50. {
    51. logMessage(FATAL, "listen socket error");
    52. exit(LISTEN_ERR);
    53. }
    54. logMessage(NORMAL, "listen socket success");
    55. }
    56. static int Accept(int listensock, std::string *clientip, uint16_t *clientport)
    57. {
    58. struct sockaddr_in peer;
    59. socklen_t len = sizeof(peer);
    60. int sock = accept(listensock, (struct sockaddr *)&peer, &len);
    61. if (sock < 0)
    62. logMessage(ERROR, "accept error, next");
    63. else
    64. {
    65. logMessage(NORMAL, "accept a new link success, get new sock: %d", sock); // ?
    66. *clientip = inet_ntoa(peer.sin_addr);
    67. *clientport = ntohs(peer.sin_port);
    68. }
    69. return sock;
    70. }
    71. };

    4、测试 

    运行服务器

    ./select_server

    客户端连接 

    telnet 127.0.0.1 8081

  • 相关阅读:
    使用Python进行云计算:AWS、Azure、和Google Cloud的比较
    vue 浏览器记住密码后,自动填充账号密码错位
    Python 爬取深交所交易日历
    c语言练习7
    CMake Day 7 —— option
    帆软报表之填报报表
    17.webpack4优化配置介绍
    Spring学习中存在报错问题汇总
    LeetCode50天刷题计划(Day 20—— 有效的数独(12.10-13.10)
    js异步控制器,实现异步同步代码交替穿插执行
  • 原文地址:https://blog.csdn.net/qq_61552595/article/details/136350910