这一年半来一直在做游戏项目逻辑层,学会了不少东西,觉得自己应该看看服务器底层的东西了,主要的东西就是网络模块,网络模块是沿用以前项目的,在 我们项目中被我们头改动过几次,现在还是比较稳定的。因为是Windows平台,所以用的依然是被大多数人神话了的IOCP,不过的确IOCP 表现的非常不错。

什么是IOCP?

众所周知,为了绝对同步,所以很多模式都采用的是同步模式,而不是异步,这样就会产生很大情况下在等待,CPU在切换时间片,从而导致效率比较低。自从MS在winsocket2中引入了IOCP这个模型之后,他才开始被大家所认知。

IOCP (I/O Completion Port),中文译作IO完成端口,他是一个异步I/O操作的API,他可以高效的将I/O事件通知给我们的应用程序,那游戏项目来说,就是客户端或者服务器。

他与Socket基础API  select()或其他异步方法不同的是,他需要讲一个Socket和一个完成端口绑定在一起,然后就可以进行网路通信了。

什么是同步/异步?

所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。按照这个定义,其实绝大多数函数都是同步调用(例如sin, isdigit等)。

异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

逻辑上通俗来讲是完成一件事再去做另外一件事情就是同步,而一起做两件或者两件以上的事情就是异步了。类似于Win32API中的SendMessage()和PostMessage(),你可以将他理解成单线程和多线程的区别。

拿游戏服务器与客户端通信来说:

如果是同步:

ClientA发送一条Msg1Req消息给Server,这个时候ClientA就会等待Server处理Msg1Req。这段时间内ClientA只有等待,因为Server还没有给ClientA回复Msg1Ack消息,所以ClientA只能痴痴的等,等到回复之后,才能处理第二条Msg2Req消息,这样无疑就会大大的降低性能,产生非常差的用户体验。

如果是异步:

ClientA发送一条Msg1Req消息给Server,ClientA有发送第二条Msg2Req消息给Server,Server会将他们都存入队列,一条一条处理,处理完之后回复给ClientA,这样用户就可以不必等待,效率就会非常高。

什么是阻塞/非阻塞?

阻塞调用是指调用结果返回之前,当前线程会被挂起。函数只有在得到结果之后才会返回。可能阻塞和同步有点类似,但是同步调用的时候线程还是激活的,而阻塞时线程会被挂起。

非阻塞调用和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

对象的阻塞模式和阻塞函数调用

对象是否处于阻塞模式和函数是不是阻塞调用有很强的相关性,但是并不是一一对应的。阻塞对象上可以有非阻塞的调用方 式,我们可以通过一定的API去轮询状态,在适当的时候调用阻塞函数,就可以避免阻塞。而对于非阻塞对象,调用特殊的函数也可以进入阻塞调用。函数 select就是这样的一个例子。

对IOCP的评价如何?

I/O完成端口可能是Win32提供的最复杂的内核对象。 Jeffrey Richter

这是实现高容量网络服务器的最佳方法。Microsoft Corporation

完成端口模型提供了最好的伸缩性。这个模型非常适用来处理数百乃至上千个套接字。Anthony Jones & Jim Ohlund

I/O completion ports特别显得重要,因为它们是唯一适用于高负载服务器[必须同时维护许多连接线路]的一个技术。Completion ports利用一些线程,帮助平衡由I/O请求所引起的负载。这样的架构特别适合用在SMP系统中产生的”scalable”服务器。 Jim Beveridge & Robert Wiener

IOCP中的完成是指什么意思?

网络通信说白了就是将一堆数据发过来发过去,到底还是数据的操作。不过大家都知道I/O操作是非常慢的,包括打印机、调制解调器、硬盘等,至少相对于CPU来说是非常慢的。坐等I/O是很浪费时间的事情,可能你只需要读取100KB的数据,假设读了0.1秒,假设CPU是3.0G Hz,那么CPU已经运行了0.3G次了,所以CPU这个时候就不满意了,哥这么NB,为什么要等你?

所以我们用另外一个线程来处理I/O操作,使用重叠IO(Overlapped I/O)技术,应用程序可以要求OS为其传输数据,在完成的时候通知应用程序,然后在进行相应操作,这也就是为什么叫完成的原因。这可以使得应用程序在I/O传输期间可以做其他事情,这也可以最大限度的利用线程,而让最NB的CPU不至于痴痴等待。

下来会将IOCP和网络有什么关系,以及IOCP的简单应用。

上一篇《IOCP浅析》中翻翻的谈了一下IOCP的简单含义,这篇稍微深入讨论下IOCP到底有什么好的,让大家将他推向神坛,同时简单的讨论下基本函数。

IOCP出现的意义?

写过网络程序的朋友应该很清楚网络程序的原型代码,startup一个WSADATA,然后建立一个监听socket对象,绑定一个服务器地址,然后开始监听,无限循环的accept来自客户端的消息,建立一个线程来处理消息,accept之后线程就被挂起了,知道收到来自客户端的消息。

这样的模型中服务器对每个客户端都会创建一个线程,优点在于等待请求的线程只做很少的事情,大部分时间该线程都在休息,因为recv函数是阻塞的。

所以这样的效率并不是很高,NT小组意识到这样CPU的大部分时间都耗费在线程的上下文切换上,线程并没有抢到cpu时间来处理自己的工作。

NT小组想到了一个解决办法,实现开好N个线程,将用户的消息都投递到一个消息队列中去,然后事先开好的N个线程逐一从消息队列中取出消息并加以处理,就可以避免为每一个客户端的请求单独开线程,既减少了线程的资源,也提高了线程的利用率。所以I/O完成端口的内核对象在NT3.5中首次被引入,MS还是比较伟大的。

这里你也看到了,IOCP其实称作是一种消息处理的机制差不多,而叫完成端口估计也是有历史原因,亦或者是因为他提供了用户与操作系统的一种接口吧。

ICOP的基本函数接口
创建完成端口

C++
1
2
3
4
5
6

HANDLE WINAPI CreateIoCompletionPort(
    __in      HANDLEFileHandle,                // An open file handle or INVALID_HANDLE_VALUE
    __in_opt  HANDLEExistingCompletionPort,    // A handle to an existing I/O completion port or NULL
    __in      ULONG_PTRCompletionKey,            // Completion key
    __in      DWORDNumberOfConcurrentThreads    // Number of threads to execute concurrently
    );

第一个参数是指一个已经打开的文件句柄或者空句柄值,一般为客户端的socket 注意:第一个参数HANDLE在创建时需要在CreateFile()中制定FILE_FLAG_OVERLAPPED标志。
第二个参数是指一个已经存在的IOCP句柄或者NULL
第三个参数是指完成Key,是一个unsigned long的指针,可以为NULL
第四个参数才是我们比较关心的,是指已经创建好的线程数,一般我们会用一个公式来计算,预设的线程数 = CPU核心数 * 2 + 2,有人也说是 + 1,我是没明白为什么要这样计算,希望大神指教。

对于第三个参数的意思,MSDN上解释如下 Use the CompletionKey parameter to help your application track which I/O operations have completed.(用来检测那些IO操作已经完成)

该函数用于两个不同的目的

1.创建一个完成端口的句柄对象
HANDLE h = CreateIoCompletionPort((HANDLE) socket, hCompletionPort, dwCompletionKey, m_nIOWorkers);

2.将一个句柄和完成端口关联在一起

在绑定每一个CLIENT到IOCP时,需要传递一个DWORD CompletionKey, 该参数为CLIENT信息的一个指针。

IO的异步调用

C++
1
2
3
4
5
6

BOOLWINAPI PostQueuedCompletionStatus(
  __in      HANDLECompletionPort,
  __in      DWORDdwNumberOfBytesTransferred,
  __in      ULONG_PTRdwCompletionKey,
  __in_opt  LPOVERLAPPEDlpOverlapped
);

第一个参数为创建的完成端口句柄
第二个参数传输了多少字节
第三个参数同样为完成键指针
第四个参数为重叠I/O buffer,其结构如下

C
1
2
3
4
5
6
7
8
9
10
11
12

typedefstruct _OVERLAPPED{
    ULONG_PTRInternal;
    ULONG_PTRInternalHigh;
    union{
        struct{
            DWORDOffset;
            DWORDOffsetHigh;
        };
        PVOID  Pointer;
    };
    HANDLE    hEvent;
}OVERLAPPED,*LPOVERLAPPED;

线程的同步

C++
1
2
3
4
5
6
7

BOOLWINAPI GetQueuedCompletionStatus(
  __in  HANDLE CompletionPort,
  __out  LPDWORDlpNumberOfBytes,
  __out  PULONG_PTRlpCompletionKey,
  __out  LPOVERLAPPED*lpOverlapped,
  __in  DWORD dwMilliseconds
);

第一个参数为创建的完成端口句柄
第二个参数同样为传输了多少字节
第三个参数同样为完成键指针
第四个参数为重叠I/O buffer
第五个参数的解释如下
The number of milliseconds that the caller is willing to wait for a completion packet to appear at the completion port. If a completion packet does not appear within the specified time, the function times out, returnsFALSE, and sets *lpOverlapped toNULL.(等待完成端口上的完成packet出现的毫秒数。如果一个完成在特殊时间内没有出现,则认为超时,返回false,同时将重叠buffer置为NULL)

大体上函数就有这么几个,大家有兴趣可以去看看MSDN上关于完成端口上的英文介绍,比较准确,中文翻译上难免出现歧义,同时还可以锻炼英文阅读。

好了,I/O完成端口的API就介绍到这里,下一篇会尝试着写出一个简单的完成端口模型来通讯,这篇可能会比较晚出来,因为自己也是摸索阶段,大家互勉。

本文是我在学习IOCP的时候,第一次写一个完整的例子出来,当然了,参考了CSDN上一些朋友的博客,大部分都是按照他们的思路写的,毕竟我是初学者,参考现成的学起来比较快。当然了,真正用到项目中的IOCP肯定不止这么简单的,还有内存池,环形缓冲区,socket连接池等高端内容,后面我会参考一些例子,写出一个完整的给大家看。

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

/************************************************************************
        FileName:iocp.h
        Author    :eliteYang
        http://www.cppfans.org
************************************************************************/
#ifndef __IOCP_H__
#define __IOCP_H__
#include
#include
#define DefaultPort 20000
#define DataBuffSize 8 * 1024
typedefstruct
{
    OVERLAPPEDoverlapped;
    WSABUFdatabuff;
    CHARbuffer[ DataBuffSize ];
    DWORDbytesSend;
    DWORDbytesRecv;
}PER_IO_OPERATEION_DATA,*LPPER_IO_OPERATION_DATA;
typedefstruct
{
    SOCKETsocket;
}PER_HANDLE_DATA,*LPPER_HANDLE_DATA;
#endif

前面讲过IOCP里面一个很重要的东西就是IO重叠了,所以结构体里有一个OVERLAPPED结构。

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233

/************************************************************************
        FileName:iocp.cpp
        Author    :eliteYang
        http://www.cppfans.org
************************************************************************/
#include "iocp.h"
#include
usingnamespace std;
#pragma comment( lib, "Ws2_32.lib" )
DWORDWINAPI ServerWorkThread(LPVOID CompletionPortID);
voidmain()
{
    SOCKETacceptSocket;
    HANDLEcompletionPort;
    LPPER_HANDLE_DATApHandleData;
    LPPER_IO_OPERATION_DATApIoData;
    DWORDrecvBytes;
    DWORDflags;
    WSADATAwsaData;
    DWORDret;
    if( ret= WSAStartup(0x0202, &wsaData ) != 0)
    {
        std::cout<< "WSAStartup failed. Error:"<< ret<< std::endl;
        return;
    }
    completionPort= CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL, 0, 0);
    if( completionPort== NULL)
    {
        std::cout<< "CreateIoCompletionPort failed. Error:"<< GetLastError()<< std::endl;
        return;
    }
    SYSTEM_INFOmySysInfo;
    GetSystemInfo(&mySysInfo );
    // 创建 2 * CPU核数 + 1 个线程
    DWORDthreadID;
    for( DWORDi =0; i <( mySysInfo.dwNumberOfProcessors* 2+ 1); ++i )
    {
        HANDLEthreadHandle;
        threadHandle= CreateThread(NULL, 0, ServerWorkThread,completionPort,0, &threadID );
        if( threadHandle== NULL)
        {
            std::cout<< "CreateThread failed. Error:"<< GetLastError()<< std::endl;
            return;
        }
        CloseHandle(threadHandle );
    }
    // 启动一个监听socket
    SOCKETlistenSocket =WSASocket( AF_INET, SOCK_STREAM,0, NULL, 0,WSA_FLAG_OVERLAPPED );
    if( listenSocket== INVALID_SOCKET)
    {
        std::cout<< " WSASocket( listenSocket ) failed. Error:"<< GetLastError()<< std::endl;
        return;
    }
    SOCKADDR_INinternetAddr;
    internetAddr.sin_family= AF_INET;
    internetAddr.sin_addr.s_addr= htonl(INADDR_ANY );
    internetAddr.sin_port= htons(DefaultPort );
    // 绑定监听端口
    if( bind(listenSocket,(PSOCKADDR)&internetAddr,sizeof( internetAddr )) ==SOCKET_ERROR )
    {
        std::cout<< "Bind failed. Error:"<< GetLastError()<< std::endl;
        return;
    }
    if( listen(listenSocket,5 )==  SOCKET_ERROR)
    {
        std::cout<< "listen failed. Error:"<< GetLastError()<< std::endl;
        return;
    }
    // 开始死循环,处理数据
    while( 1)
    {
        acceptSocket= WSAAccept(listenSocket,NULL, NULL, NULL,0 );
        if( acceptSocket== SOCKET_ERROR)
        {
            std::cout<< "WSAAccept failed. Error:"<< GetLastError()<< std::endl;
            return;
        }
        pHandleData= (LPPER_HANDLE_DATA)GlobalAlloc(GPTR, sizeof( PER_HANDLE_DATA) );
        if( pHandleData= NULL)
        {
            std::cout<< "GlobalAlloc( HandleData ) failed. Error:"<< GetLastError()<< std::endl;
            return;
        }
        pHandleData->socket= acceptSocket;
        if( CreateIoCompletionPort((HANDLE)acceptSocket,completionPort,(ULONG_PTR)pHandleData,0 )== NULL)
        {
            std::cout<< "CreateIoCompletionPort failed. Error:"<< GetLastError()<< std::endl;
            return;
        }
        pIoData= (LPPER_IO_OPERATION_DATA )GlobalAlloc(GPTR, sizeof( PER_IO_OPERATEION_DATA) );
        if( pIoData== NULL)
        {
            std::cout<< "GlobalAlloc( IoData ) failed. Error:"<< GetLastError()<< std::endl;
            return;
        }
        ZeroMemory(&( pIoData->overlapped), sizeof( pIoData->overlapped) );
        pIoData->bytesSend= 0;
        pIoData->bytesRecv= 0;
        pIoData->databuff.len= DataBuffSize;
        pIoData->databuff.buf= pIoData->buffer;
        flags= 0;
        if( WSARecv(acceptSocket,&(pIoData->databuff),1, &recvBytes, &flags, &(pIoData->overlapped),NULL )== SOCKET_ERROR)
        {
            if( WSAGetLastError()!= ERROR_IO_PENDING)
            {
                std::cout<< "WSARecv() failed. Error:"<< GetLastError()<< std::endl;
                return;
            }
            else
            {
                std::cout<< "WSARecv() io pending"<< std::endl;
                return;
            }
        }
    }
}
DWORD WINAPI ServerWorkThread(LPVOID CompletionPortID)
{
    HANDLEcomplationPort =(HANDLE)CompletionPortID;
    DWORDbytesTransferred;
    LPPER_HANDLE_DATApHandleData =NULL;
    LPPER_IO_OPERATION_DATApIoData =NULL;
    DWORDsendBytes =0;
    DWORDrecvBytes =0;
    DWORDflags;
    while( 1)
    {
        if( GetQueuedCompletionStatus(complationPort,&bytesTransferred,(PULONG_PTR)&pHandleData,(LPOVERLAPPED*)&pIoData,INFINITE )==0)
        {
            std::cout<< "GetQueuedCompletionStatus failed. Error:"<< GetLastError()<< std::endl;
            return0;
        }
        // 检查数据是否已经传输完了
        if( bytesTransferred== 0)
        {
            std::cout<< " Start closing socket..."<< std::endl;
            if( CloseHandle((HANDLE)pHandleData->socket) ==SOCKET_ERROR )
            {
                std::cout<< "Close socket failed. Error:"<< GetLastError()<< std::endl;
                return0;
            }
            GlobalFree(pHandleData );
            GlobalFree(pIoData );
            continue;
        }
        // 检查管道里是否有数据
        if( pIoData->bytesRecv== 0)
        {
            pIoData->bytesRecv= bytesTransferred;
            pIoData->bytesSend= 0;
        }
        else
        {
            pIoData->bytesSend+= bytesTransferred;
        }
        // 数据没有发完,继续发送
        if( pIoData->bytesRecv> pIoData->bytesSend)
        {
            ZeroMemory(&(pIoData->overlapped),sizeof( OVERLAPPED ));
            pIoData->databuff.buf= pIoData->buffer+ pIoData->bytesSend;
            pIoData->databuff.len= pIoData->bytesRecv- pIoData->bytesSend;
            // 发送数据出去
            if( WSASend(pHandleData->socket,&(pIoData->databuff),1, &sendBytes, 0, &(pIoData->overlapped),NULL )== SOCKET_ERROR)
            {
                if( WSAGetLastError()!= ERROR_IO_PENDING)
                {
                    std::cout<< "WSASend() failed. Error:"<< GetLastError()<< std::endl;
                    return0;
                }
                else
                {
                    std::cout<< "WSASend() failed. io pending. Error:"<< GetLastError()<< std::endl;
                    return0;
                }
            }
            std::cout<< "Send "<< pIoData->buffer<< std::endl;
        }
        else
        {
            pIoData->bytesRecv= 0;
            flags= 0;
            ZeroMemory(&(pIoData->overlapped),sizeof( OVERLAPPED ));
            pIoData->databuff.len= DataBuffSize;
            pIoData->databuff.buf= pIoData->buffer;
            if( WSARecv(pHandleData->socket,&(pIoData->databuff),1, &recvBytes, &flags, &(pIoData->overlapped),NULL )== SOCKET_ERROR)
            {
                if( WSAGetLastError()!= ERROR_IO_PENDING)
                {
                    std::cout<< "WSARecv() failed. Error:"<< GetLastError()<< std::endl;
                    return0;
                }
                else
                {
                    std::cout<< "WSARecv() io pending"<< std::endl;
                    return0;
                }
            }
        }
    }
}

整个过程还是类似于最基础的socket连接方式,主要部分就是使用IOCP的两个函数,创建IOCP和检测当前的状态。

大家先凑活看吧,后面本博客会有更精彩的IOCP内容呈现给大家,我也是逐步在学习,大家稍安勿躁。

IOCP 浅析与实例相关推荐

  1. IOCP结合AcceptEx实例

    在普通IOCP的基础上注意两点: 1.记得把监听socket绑定到端口 2.在Accept处理过程中,抛出接受连接的AcceptEx请求,绑定客户端socket到端口和抛出recv请求 客户端要断开连 ...

  2. AcceptEx与完成端口(IOCP)结合实例

    前言 在windows平台下实现高性能网络服务器,iocp(完成端口)是唯一选择.编写网络服务器面临的问题有:1 快速接收客户端的连接.2 快速收发数据.3 快速处理数据.本文主要解决第一个问题. A ...

  3. 浅析epoll-为何多路复用I/O要使用epoll

    本文转自C++爱好者博客 http://www.cppfans.org/author/eliteyang,顺便记录一下自己学习epoll的过程. 现如今,网络通讯中用epoll(linux)和IOCP ...

  4. uicolor swift_Swift中的UIColor

    uicolor swift UIColor (UIColor) An object that stores color data and sometimes opacity. 存储颜色数据和有时不透明 ...

  5. java 正则 惰性匹配_正则表达式 - 贪婪与非贪婪(惰性)

    使用场景 有时,我们想用正则匹配以某个子串开头,且以某个子串或字符结尾的子字符串,但是结尾的字串或字符在原字符串中出现了多次,但我们只想匹配从开始处到第一次出现的地方,换句话说,想得到开始和结尾之间内 ...

  6. Rxswift学习之(一)函数响应式编程思想

    Rxswift学习之(一)函数响应式编程思想 1. 函数响应式编程思想必备基本概念简介 2. iOS中三种编程思想:链式.函数式和响应式编程 2.1 链式编程 2.2 函数式编程 2.3 响应式编程 ...

  7. c语言plc库,PLC编程-C语言.ppt

    PLC编程-C语言.ppt 华中数控培训讲义 PLC编程,C语言编程,PLC控制的范围,数控机床所受到的控制可分为两类数字控制和顺序控制. 数字控制主要指对各进给轴进行精确的位置控制,包括轴移 动距离 ...

  8. c语言和plc编程,PLC编程-C语言PPT学习课件

    华中数控培训讲义PLC编程,C语言编程,1,,PLC控制的范围,数控机床所受到的控制可分为两类:数字控制和顺序控制.数字控制主要指对各进给轴进行精确的位置控制,包括:轴移动距离.插补.补偿等.顺序控制 ...

  9. 前端开发基础知识汇总

    一.HTML 1.前言与常用标签 浏览器 内核 备注 IE Trident IE.猎豹安全.360极速浏览器.百度浏览器 firefox Gecko 可惜这几年已经没落了,打开速度慢.升级频繁.猪一样 ...

最新文章

  1. java drools5_Java Drools5.1 规则流基础【示例】
  2. element 修改表单值后表单验证无效_javascript自学记录:表单脚本1
  3. CCF NOI1063 计算组合数
  4. Xgboost简易入门教程
  5. java字符串学习_java之字符串学习记录
  6. 玩转 SpringBoot 2 之发送邮件篇
  7. 关于Ajax的get与post浅分析,同步请求与异步请求,跨域请求;
  8. Mysql合并两个sql结果
  9. python工资高还是java-未来Java、大数据、Python哪个前景更好,薪资更高?
  10. 再议Python协程——从yield到asyncio
  11. 小爬需登录的网站之麦子学院
  12. PostgreSQL eighth class
  13. 【对讲机的那点事】手把手教你给摩托罗拉C1200数字对讲机写频
  14. 什么样的恐怖才是真恐怖?由最近所看的一部电影以及最喜欢的游戏系列想到的。
  15. win10电脑如何远程连接云服务器?
  16. 小米应用商店如何做优化?有哪些方式 ?
  17. 根域名服务器的一点理解
  18. 2020年全球及中国术后镇痛药行业市场现状分析,非阿片类药物需求不断增长「图」
  19. 【逗老师带你学IT】Kiwi Syslog转存MySQL数据库
  20. 声呐学习笔记之概念性理论

热门文章

  1. flume案例-flume级联-组件分析
  2. 数据库-SQL分类介绍及总结
  3. 循环计算-偶数求和-计算结果
  4. xxl-job 2.1.1执行器源码解读
  5. qt 运行库 linux,linux(ubuntu) 版qt5.x安装的一些知识
  6. android 等待动画 库,android--AnimationDrawable实现等待动画效果
  7. if-else运用及技巧(C# 参考)
  8. [NOI2018]你的名字
  9. 五周第四次课(4月23日)
  10. kickstart 安装CentOS GPT分区的完整ks示例