版权说明:本文档由用户提供并上传,收益归属内容提供方,若内容存在侵权,请进行举报或认领
文档简介
1/1完成端口加线程池技术实现WinSock异步I/O模型[5]完成端口-CompletionPort
如果你想在Windows平台上构建服务器应用,那么I/O模型是你必须考虑的。Windows操作系统提供了五种I/O模型,分别是:
■选择(select);
■异步选择(WSAAsyncSelect);
■事件选择(WSAEventSelect);
■重叠I/O(OverlappedI/O);
■完成端口(CompletionPort)。
每一种模型适用于一种特定的应用场景。程序员应该对自己的应用需求非常明确,综合考虑到程序的扩展性和可移植性等因素,作出自己的选择。
==============================================
█“完成端口”模型是迄今为止最复杂的一种I/O模型。但是,若一个应用程序同时需要管理很多的套接字,
那么采用这种模型,往往可以达到最佳的系统性能!但缺点是,该模型只适用于WindowsNT和Windows2000以上版本的操作系统。
█因其设计的复杂性,只有在你的应用程序需要同时管理数百乃至上千个套接字的时候,而且希望随着系统内安装的CPU数量的增多,
应用程序的性能也可以线性提升,才应考虑采用“完成端口”模型。
█从本质上说,完成端口模型要求我们创建一个Win32完成端口对象,通过指定数量的线程,
对重叠I/O请求进行管理,以便为已经完成的重叠I/O请求提供服务。
█※※※大家可以这样理解,一个完成端口其实就是一个完成I/O的通知队列,由操作系统把已经完成的重叠I/O请求的通知放入这个队列中。
当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工线程就会收到一则通知,工线程再去做一些其他的善后工作,
比如:将收到的数据进行显示,等等。而套接字在被创建后,可以在任何时候与某个完成端口进行关联。※※※
通常情况下,我们会在应用程序中创建一定数量的工线程来处理这些通知。线程数量取决于应用程序的特定需要。理想的情况是,线程数量等于处理器的数量,不过这也要求任何线程都不应该执行诸如同步读写、等待事件通知等阻塞型的操作,以免线程阻塞。每个线程都将分到一定的CPU时间,在此期间该线程可以运行,然后另一个线程将分到一个时间片并开始执行。如果某个线程执行了阻塞型的操作,操作系统将剥夺其未使用的剩余时间片并让其它线程开始执行。也就是说,前一个线程没有充分使用其时间片,当发生这样的情况时,应用程序应该准备其它线程来充分利用这些时间片。
█使用这种模型之前,首先要创建一个I/O完成端口对象,用它面向任意数量的套接字句柄,管理多个I/O请求。
要做到这一点,需要调用CreateCompletionPort函数,其定义如下:
HANDLEWINAPICreateIoCompletionPort(
__inHANDLEFileHandle,
__inHANDLEExistingCompletionPort,
__inULONG_PTRCompletionKey,
__inDWORDNumberOfConcurrentThreads
);
要注意该函数有两个功能:
●用于创建一个完成端口对象;
●将一个句柄同完成端口对象关联到一起。
如果仅仅为了创建一个完成端口对象,唯一注意的参数便是NumberOfConcurrentThreads(并发线程的数量),前面三个参数可忽略。
NumberOfConcurrentThreads参数的特殊之处在于,它定义了在一个完成端口上,同时允许执行的线程数量。
理想情况下,我们希望每个处理器各自负责一个线程的运行,为完成端口提供服务,避免过于频繁的线程“场景”(即线程上下文)切换。
若将该参数设为0,表明系统内安装了多少个处理器,便允许同时运行多少个工线程!可用下述代码创建一个I/O完成端口:
HANDLECompletionPort=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0);
★1、工线程与完成端口
成功创建一个完成端口后,便可开始将套接字句柄与其关联到一起。但在关联套接字之前,首先必须创建一个或多个“工线程”,
以便在I/O请求投递给完成端口后,为完成端口提供服务。在这个时候,大家或许会觉得奇怪,到底应创建多少个线程,以便为完成端口提供服务呢?
在此,要记住的一点,我们调用CreateIoComletionPort时指定的并发线程数量,与打算创建的工线程数量相比,它们代表的不是同一件事情。
CreateIoCompletionPort函数的NumberOfConcurrentThreads参数明确指示系统:
在一个完成端口上,一次只允许n个工线程运行。假如在完成端口上创建的工线程数量超出n个,那么在同一时刻,最多只允许n个线程运行。
但实际上,在一段较短的时间内,系统有可能超过这个值,但很快便会把它减少至事先在CreateIoCompletionPort函数中设定的值。
那么,为何实际创建的工线程数量有时要比CreateIoCompletionPort函数设定的
多一些呢?这样做有必要吗?
这主要取决于应用程序的总体设计情况。假定我们的某个工线程调用了一个函数,比如Sleep或WaitForSingleObject,
进入了暂停(锁定或挂起)状态,那么允许另一个线程代替它的位置。换言之,我们
希望随时都能执行尽可能多的线程;
当然,最大的线程数量是事先在CreateIoCompletonPort调用里设定好的。这样一来,假如事先预计到自己的线程有可能暂时处于停顿状态,
那么最好能够创建比CreateIoCompletonPort的NumberOfConcurrentThreads参数的值多的线程,以便到时候充分发挥系统的潜力。
==========================================
每一种模型适用于一种特定的应用场景。程序员应该对自己的应用需求非常明确,
综合考虑到程序的扩展性和可移植性等因素,作出自己的选择。
==============================================
█“完成端口”模型是迄今为止最复杂的一种I/O模型。但是,若一个应用程序同时
需要管理很多的套接字,
那么采用这种模型,往往可以达到最佳的系统性能!但缺点是,该模型只适用于WindowsNT和Windows2000以上版本的操作系统。
█因其设计的复杂性,只有在你的应用程序需要同时管理数百乃至上千个套接字的时候,而且希望随着系统内安装的CPU数量的增多,
应用程序的性能也可以线性提升,才应考虑采用“完成端口”模型。
█从本质上说,完成端口模型要求我们创建一个Win32完成端口对象,通过指定数量的线程,
对重叠I/O请求进行管理,以便为已经完成的重叠I/O请求提供服务。
█※※※大家可以这样理解,一个完成端口其实就是一个完成I/O的通知队列,由操作系统把已经完成的重叠I/O请求的通知放入这个队列中。
当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工线程就会收到一则通知,工线程再去做一些其他的善后工作,
比如:将收到的数据进行显示,等等。而套接字在被创建后,可以在任何时候与某个完成端口进行关联。※※※
通常情况下,我们会在应用程序中创建一定数量的工线程来处理这些通知。线程数量取决于应用程序的特定需要。理想的情况是,线程数量等于处理器的数量,不过这也要求任何线程都不应该执行诸如同步读写、等待事件通知等阻塞型的操作,以免线程阻塞。每个线程都将分到一定的CPU时间,在此期间该线程可以运行,然后另一个线程将分到一个时间片并开始执行。如果某个线程执行了阻塞型的操作,操作系统将剥夺其未使用的剩余时间片并让其它线程开始执行。也就是说,前一个线程没有充分使用其时间片,当发生这样的情况时,应用程序应该准备其它线程来充分利用这些时间片。
█使用这种模型之前,首先要创建一个I/O完成端口对象,用它面向任意数量的套接字句柄,管理多个I/O请求。
要做到这一点,需要调用CreateCompletionPort函数,其定义如下:
HANDLEWINAPICreateIoCompletionPort(
__inHANDLEFileHandle,
__inHANDLEExistingCompletionPort,
__inULONG_PTRCompletionKey,
__inDWORDNumberOfConcurrentThreads
);
要注意该函数有两个功能:
●用于创建一个完成端口对象;
●将一个句柄同完成端口对象关联到一起。
如果仅仅为了创建一个完成端口对象,唯一注意的参数便是NumberOfConcurrentThreads(并发线程的数量),前面三个参数可忽略。
NumberOfConcurrentThreads参数的特殊之处在于,它定义了在一个完成端口上,同时允许执行的线程数量。
理想情况下,我们希望每个处理器各自负责一个线程的运行,为完成端口提供服务,避免过于频繁的线程“场景”(即线程上下文)切换。
若将该参数设为0,表明系统内安装了多少个处理器,便允许同时运行多少个工线程!可用下述代码创建一个I/O完成端口:
HANDLECompletionPort=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0);
★1、工线程与完成端口
成功创建一个完成端口后,便可开始将套接字句柄与其关联到一起。但在关联套接字之前,首先必须创建一个或多个“工线程”,
以便在I/O请求投递给完成端口后,为完成端口提供服务。在这个时候,大家或许会觉得奇怪,到底应创建多少个线程,以便为完成端口提供服务呢?
在此,要记住的一点,我们调用CreateIoComletionPort时指定的并发线程数量,与打算创建的工线程数量相比,它们代表的不是同一件事情。
CreateIoCompletionPort函数的NumberOfConcurrentThreads参数明确指示系统:
在一个完成端口上,一次只允许n个工线程运行。假如在完成端口上创建的工线程数量超出n个,那么在同一时刻,最多只允许n个线程运行。
但实际上,在一段较短的时间内,系统有可能超过这个值,但很快便会把它减少至事
先在CreateIoCompletionPort函数中设定的值。
那么,为何实际创建的工线程数量有时要比CreateIoCompletionPort函数设定的
多一些呢?这样做有必要吗?
这主要取决于应用程序的总体设计情况。假定我们的某个工线程调用了一个函数,比如Sleep或WaitForSingleObject,
进入了暂停(锁定或挂起)状态,那么允许另一个线程代替它的位置。换言之,我们
希望随时都能执行尽可能多的线程;
当然,最大的线程数量是事先在CreateIoCompletonPort调用里设定好的。这样一来,假如事先预计到自己的线程有可能暂时处于停顿状态,
那么最好能够创建比CreateIoCompletonPort的NumberOfConcurrentThreads参数的值多的线程,以便到时候充分发挥系统的潜力。
==========================================
一旦在完成端口上拥有足够多的工线程来为I/O请求提供服务,便可着手将套接
字句柄同完成端口关联到一起。
这要求我们在一个现有的完成端口上,调用CreateIoCompletionPort函数,同时为前
三个参数—FileHandle,ExistingCompletionPort和CompletionKey—提供套接字的信息。
●FileHandle参数指定一个要同完成端口关联在一起的套接字句柄;
●ExistingCompletionPort参数指定的是一个现有的完成端口;
●CompletionKey(完成键)参数指定与某个套接字句柄关联在一起的“单句柄数据”,可将其作为指向一个数据结构的指针,
在此数据结构中,同时包含了套接字的句柄,以及与套接字有关的其他信息,如IP地址等。为完成端口提供服务的线程函数可通过这个参数,取得与套接字句柄有关的信息。
根据目前,首先来构建一个基本的应用程序框架。下面的程序清单向
大家阐述了如何使用完成端口模型,来开发一个服务器应用。在这个程序中,
我们按照以下步骤进行:
1)创建一个完成端口,第四个参数保持为0,指定在完成端口上,每个处理器一次只允许执行一个工线程;
2)判断系统内到底安装了多少个处理器;
3)创建工线程,根据步骤2)得到的处理器信息,在完成端口上,为已完成的I/O请求提供服务,在这个简单的例子中,我们为每个处理器都只创建一个工线程。
这是由于事先已预计到,到时不会有任何线程进入“挂起”状态,造成由于线程数量的不足,而使处理器空闲的局面(没有足够的线程可供执行)。
调用CreateThread函数时,必须同时提供一个工例程,由线程在创建好执行;
4)准备好一个监听套接字,在端口9527上监听进入的连接请求;
5)使用accept函数,接受进入的连接请求;
6)创建一个数据结构,用于容纳“单句柄数据”,同时在结构中存入接受的套接字句柄;
7)调用CreateIoCompletionPort函数,将从accept返回的新套接字句柄同完成端口关联到一起,
通过完成键(CompletionKey)参数,将单句柄数据结构传递给CreateIoCompletionPort函数;
8)开始在已接受的连接上进行I/O操作,在此,我们希望通过重叠I/O机制,在新建的套接字上投递一个或多个异步WSARecv或WSASend请求。
这些I/O请求完成后,一个工线程会为I/O请求提供服务,同时继续处理未来的其他I/O请求,
稍后便会在步骤3)指定的工例程中,体验到这一点;
9)重复步骤5)~8),直至服务器中止。
代码如下:
HANDLECompletionPort;
WSADATAwsd;
SYSTEM_INFOSystemInfo;
SOCKADDR_INInternetAddr;
SOCKETListen;
inti;
typedefstruct_PER_HANDLE_DATA
{
SOCKETSocket;
SOCKADDR_STORAGEClientAddr;
//Otherinformationusefultobeassociatedwiththehandle}PER_HANDLE_DATA,*LPPER_HANDLE_DATA;
//LoadWinsock
StartWinsock(MAKEWORD(2,2),
//Step1:
//创建一个完成端口
CompletionPort=CreateIoCompletionPort(
INVALID_HANDLE_VALUE,NULL,0,0);
//Step2:
//判断系统内到底安装了多少个处理器
GetSystemInfo(
//Step3:
//根据处理器的数量创建工线程
for(i=0;iSocket=Accept;
memcpy(
//Step7:
//调用CreateIoCompletionPort函数,将从accept返回的新套接字句柄同完成端口关联到一起
CreateIoCompletionPort((HANDLE)Accept,
CompletionPort,(DWORD)PerHandleData,0);
//Step8:
//开始在已接受的连接上进行I/O操作
WSARecv(...);
}
DWORDWINAPIServerWorkerThread(LPVOIDlpParam)
{
//Therequirementsfortheworkerthreadwillbe
//discussedlater.
return0;
}
★2、完成端口和重叠I/O(工线程要做的事情)
将套接字句柄与一个完成端口关联在一起后,便可投递发送与接收请求,开始对I/O请求的处理。
接下来,可开始依赖完成端口,来接收有关I/O操作完成情况的通知。
从本质上说,完成端口模型利用了Win32重叠I/O机制。在这种机制中,象WSASend和WSARecv这样的WinsockAPI调用会立即返回。
此时,需要由我们的应用程序负责在以后的某个时间,通过一个OVERLAPPED结构,来接收之前调用请求的结果。
在完成端口模型中,要想做到这一点,需要使用GetQueuedCompletionStatus(获取
排队完成状态)函数,
让一个或多个工线程在完成端口上等待I/O请求完成的通知。该函数的定义如下:BOOLWINAPIGetQueuedCompletionStatus(
__inHANDLECompletionPort,
__outLPDWORDlpNumberOfBytes,
__outPULONG_PTRlpCompletionKey,
__outLPOVERLAPPED*lpOverlapped,
__inDWORDdwMilliseconds
);
●CompletionPort参数对应于要在上面等待的完成端口;
●lpNumberOfBytes参数负责在完成了一次I/O操作后(如:WSASend或WSARecv),接收实际传输的字节数。
●lpCompletionKey参数为原先传递给CreateIoCompletionPort函数第三个参数“单句柄数据”,如我们早先所述,大家最好将套接字句柄保存在这个“键”(Key)中。
●lpOverlapped参数用于接收完成I/O操作的重叠结果。这实际是一个相当重要的参数,因为可用它获取每个I/O操作的数据。
●dwMilliseconds参数用于指定希望等待一个完成数据包在完成端口上出现的时间,即,超时时间。假如将其设为INFINITE,会一直等待下去。
★3、“单句柄数据”和单I/O操作数据
一个工线程从GetQueuedCompletionStatus函数接收到I/O完成通知后,在lpCompletionKey和lpOverlapped参数中,
会包含一些重要的套接字信息。利用这些信息,可通过完成端口,继续在一个套接字
上进行其他的处理。
通过这些参数,可获得两方面重要的套接字数据:“单句柄数据”以及单I/O操作数据。
其中,lpCompletionKey参数包含了“单句柄数据”,因为在一个套接字首次与完成端口关联到一起的时候,
那些数据便与一个特定的套接字句柄对应起来了。这些数据正是我们在调用CreateIoCompletionPort函数时候,通过CompletionKey参数传递的。
通常情况下,应用程序会将与I/O请求有关的套接字句柄及其他的一些相关信息保存在这里;
lpOverlapped参数则包含了一个OVERLAPPED结构,在它后面跟随“单I/O操作数据”。
单I/O操作数据可以是追加到一个OVERLAPPED结构末尾的、任意数量的字节。
假如一个函数要求用到一个OVERLAPPED结构,我们便必须将这样的一个结构传递进去,以满足它的要求。
要想做到这一点,一个简单的方法是定义一个结构,然后将OVERLAPPED结构作为新结构的第一个元素使用。
举个例子来说,可定义下述数据结构,实现对单I/O操作数据的管理:
typedefstruct
{
OVERLAPPEDOverlapped;
WSABUFDataBuf;
charszBuffer[DATA_BUF_SIZE];
intOperationType;
}PER_IO_OPERATION_DATA;
该结构演示了通常与I/O操作关联的一些重要的数据元素,比如刚才完成的那个I/O操作的类型(发送或接收请求),用OperationType字段表示,
同时,用于已完成I/O操作数据的缓冲区szBuffer也是非常有用的。如果想调用一个WinsockAPI函数(如:WSASend、WSARecv),要为其分配一个OVERLAPPED结构,
这时,就可以将我们的结构强制转换成一个OVERLAPPED指针,或者从结构中将OVERLAPPED元素的地址取出来。如下例所示:
PER_IO_OPERATION_DATAPerIoData;
……
//可以这样调用:
WSARecv(socket,...,(OVERLAPPED*)
//也可以这样调用:
WSARecv(socket,...,
在工作线程的后面部分,等GetQueuedCompletionStatus函数返回了一个重叠结构(和完成键)后,
便可通过OperationType成员,看出到底是哪个操作投递到了这个句柄之上(只需将
返回的重叠结强制转换为自己的PER_IO_OPERATION_DATA结构)。
对单I/O操作数据来说,它最大的一个优点便是允许我们在同一个句柄上,同时管理
多个I/O操作(读/写,多个读,多个写,等等)。
DWORDWINAPIServerWorkerThread(LPVOIDCompletionPortID)
{
HANDLECompletionPort=(HANDLE)CompletionPortID;
DWORDBytesTransferred;
LPOVERLAPPEDOverlapped;
LPPER_HANDLE_DATAPerHandleData;
LPPER_IO_DATAPerIoData;
DWORDSendBytes,RecvBytes;
DWORDFlags;
while(TRUE)
{
//WaitforI/Otocompleteonanysocket
//associatedwiththecompletionport
ret=GetQueuedCompletionStatus(CompletionPort,
//Firstchecktoseeifanerrorhasoccurred
//onthesocket;ifso,closethe
//socketandcleanuptheper-handledata
//andper-I/Ooperationdataassociatedwith
//thesocket
if(BytesTransferred==0
GlobalFree(PerHandleData);
GlobalFree(PerIoData);
continue;
}
//ServicethecompletedI/Orequest.Youcan
//determinewhichI/Orequesthasjust
//completedbylookingattheOperationType
//fieldcontainedintheper-I/Ooperationdata.
if(PerIoData->OperationType==RECV_POSTED){
//Dosomethingwiththereceiveddata
//inPerIoData->Buffer
}
//PostanotherWSASendorWSARecvoperation.//Asanexample,wewillpostanotherWSARecv()//I/Ooperation.
Flags=0;
//Setuptheper-I/Ooperationdataforthenext//overlappedcall
ZeroMemory(
PerIoData->DataBuf.len=DATA_BUFSIZE;
PerIoData->DataBuf.buf=PerIoData->Buffer;
PerIoData->OperationType=RECV_POSTED;
WSARecv(PerHandleData->Socket,
}
★4、正确地关闭I/O完成端口
如何正确地关闭I/O完成端口,特别是同时运行了一个或多个线程,在几个不同的套
接字上执行I/O操作的时候。
要避免的一个重要问题是在进行重叠I/O操作的同时,强行释放一个OVERLAPPED结构。
要想避免出现这种情况,最好的办法是针对每个套接字句柄,调用closesocket函数,任何尚未进行的重叠I/O操作都会完成。一旦所有套接字句柄都已关闭,
便需在完成端口上,终止所有工线程的运行。要想做到这一点,需要使用PostQueuedCompletionStatus函数,向每个工线程都发送一个特殊的完成数据包。
该函数会指示每个线程都“立即结束并退出”。下面是PostQueuedCompletionStatus函数的定义:
BOOLWINAPIPostQueuedCompletionStatus(
__inHANDLECompletionPort,
__inDWORDdwNumberOfBytesTransferred,
__inULONG_PTRdwCompletionKey,
__inLPOVERLAPPEDlpOverlapped
);
●CompletionPort参数指定想向其发送一个完成数据包的完成端口对象;
●而就dwNumberOfBytesTransferred、dwCompletionKey和lpOverlapped三个参数
来说,每一个都允许我们指定一个值,
直接传递给GetQueuedCompletionStatus函数中对应的参数。这样一来,一个工线程收到传递过来的三个GetQueuedCompletionStatus函数参数后,
便可根据由这三个参数的某一个设置的特殊值,决定何时或者应该怎样退出。
例如,可用dwCompletionPort参数传递0值,而一个工线程会将其解释成中止
指令。
一旦所有工线程都已关闭,便可使用CloseHandle函数,关闭完成端口,最终安
全退出程序。
==========================================
1、线程池的基本原理
在传统服务器架构中,常常是有一个总的监听线程监听有没有新的用户连接服务器,每当有一个新的用户连接进入,服务器端就开启一个新的线程去处理这个用户的请求,与其进行数据的收发。
这个线程只服务于这个用户,当用户与服务器端关闭连接以后,服务器端才销毁这个线程。然而频繁地开辟与销毁线程极大地占用了系统的资源。而且在大量用户的情况下,少则1000,多则上万,系统为了开辟和销毁线程将浪费大量的时间和资源。
线程池技术很好的解决了这个问题,它的基本思想就是在程序开始时就在内存中开辟一些线程,当有新的客户请求到达时,
不是新创建一个线程为其服务,而是从“池子”中选择一个空闲的线程为新的客户请求服务,服务完毕后,线程不是退出,而是进入空闲线程池中。
通过对多个任务重用已经存在的线程对象,降低了对线程对象创建和销毁的开销。当客户请求时,线程对象已经存在,可以提高请求的响应时间,从而整体地提高了系统服务的表现。
2、线程池的实现
如果大家有时间的话,可以自己实现一个高效的线程池,当然网上也有很多版本的线程池源代码,大家可
温馨提示
- 1. 本站所有资源如无特殊说明,都需要本地电脑安装OFFICE2007和PDF阅读器。图纸软件为CAD,CAXA,PROE,UG,SolidWorks等.压缩文件请下载最新的WinRAR软件解压。
- 2. 本站的文档不包含任何第三方提供的附件图纸等,如果需要附件,请联系上传者。文件的所有权益归上传用户所有。
- 3. 本站RAR压缩包中若带图纸,网页内容里面会有图纸预览,若没有图纸预览就没有图纸。
- 4. 未经权益所有人同意不得将文件中的内容挪作商业或盈利用途。
- 5. 人人文库网仅提供信息存储空间,仅对用户上传内容的表现方式做保护处理,对用户上传分享的文档内容本身不做任何修改或编辑,并不能对任何下载内容负责。
- 6. 下载文件中如有侵权或不适当内容,请与我们联系,我们立即纠正。
- 7. 本站不保证下载资源的准确性、安全性和完整性, 同时也不承担用户因使用这些下载资源对自己和他人造成任何形式的伤害或损失。
最新文档
- 二零二四年度电商云平台托管服务合同2篇
- 电监会购售电合同范例
- 墙面喷漆合同模板
- 2024年度旧城区改造劳务分包合同
- 农贸市场环境综合整治工作方案样本(3篇)
- 商厦车位租赁合同范例
- 礼品卡代理合同范例
- 2024年度企业形象设计与推广合同协议书
- 红酒活动策划合同范例
- 农资打假专项治理工作方案模版(2篇)
- 2024年度智能家居解决方案合同
- 2024-2030年中国汽车再制造行业产销量预测及投资战略研究报告
- 2024厨房设备购销合同
- 小学信息科技《数据与编码-探索生活中的“编码”》教学设计
- 2024年云网安全应知应会考试题库
- 双碳综合能源平台方案建设
- 铁道概论(第八版)佟立本主编
- 大学生心理健康教育(第二版)PPT全套完整教学课件
- DL-T 5190.1-2022 电力建设施工技术规范 第1部分:土建结构工程(附条文说明)
- 关于河道管理范围内建设项目防洪影响咨询服务费计列的指导意见
- 空调维保验收单
评论
0/150
提交评论