孙鑫VC学习笔记:多线程编程
SkySeraph Dec 11st 2010 HQU
Email:[email protected] QQ:452728574
Latest Modified Date:Dec.11st 2010 HQU
=================================================================================
程序&进程&线程
=================================================================================
程序 |
计算机指令的集合,它以文件的形式存储在磁盘上 |
进程 |
通常被定义为一个正在运行的程序的实例,是一个程序在其自身的地址空间中的一次执行活动 |
区别:进程是资源申请、调度和独立运行的单位,因此,它使用系统中的运行资源;而程序不能申请系统资源,不能被系统调度,也不能作为独立的运行的单位, 因此,他不占用系统的运行资源。
1、操作系统用来管理进程的内核对象。内核对象是操作系统内部分配的一个内存块,内核对象也是系统用来存放关于进程的统计信息的地方。
2、地址空间。它包含所有可执行模块或DLL模块的代码和数据。他还包含动态内存分配的空间。如线程堆栈和堆分配空间。
内核对象:是操作系统内部分配的一个内存块,它是一种只能被内核访问的数据结构, 其成员负责维护该对象的各种信息,应用程序无法找到并直接改变它们的内容,只能通过Windows提供的函数对内核对象进行操作。
进程是不活泼的。进程从来不执行任何东西,它只是线程的容器。
若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程,此线程负责执行包含在进程的地址空间中的代码。
单个进程可能包含若干个线程,这些线程都“同时”执行进程地址空间中的代码。
每个进程至少拥有一个线程,来执行进程的地址空间中的代码。
当创建一个进程时,操作系统会自动创建这个进程的一个线程,称为主线程。此后,该线程可以创建其他的线程
线程有两个部分组成:
1。线程的内核对象,操作系统用它来对线程实施管理,内核对象也是系统用来存放线程统计信息的地方。
2。线程堆栈,它用于维护线程在执行代码时需要的所有参数和局部变量。
当创建线程时,系统创建一个线程内核对象。
该线程内核对象不是线程本身,而是操作系统用来管理线程的较小的数据结构。
可以将线程内核对象视为由关于线程的统计信息组成的一个小型数据结构。
线程总是在某个进程环境中创建。
系统从进程的地址空间中分配内存,供线程的堆栈使用。
新线程运行的进程环境与创建线程的环境相同。
因此,新线程可以访问进程的内核对象的所有句柄、进程中的所有内存和在这个相同的进程中的所有其他线程的堆栈。这使得单个进程中的多个线程确实能够非常容易的互相通信。
线程只有一个内核对象和一个堆栈,保留的记录很少,因此所需要的内存也很少。
因为线程需要的开销比进程少,因此在编程中经常采用多线程来解决编程问题,而尽量避免创建新的进程。
对于单个CPU
操作系统为每一个运行线程安排一定的CPU时间——时间片。
系统通过一种循环的方式为线程提供时间片,线程在自己的时间内运行,因时间片相当短,因此,给用户的感觉,就好像线程是同时进行的一样。
如果计算机拥有多个CPU,线程就能真正意义上运行了
我们可以用多进程代替多线程,但是这样不是明智的,因为
1.每新建一个进程,系统要为之分配4GB的虚拟内存,浪费资源;而多线程共享同一个地址空间,占用资源较少
2.在进程之间发生切换时,要交换整个地址空间;而线程之间的切换只是执行环境的改变, 效率较高。
=================================================================================
线程的创建
=================================================================================
=================================================================================
#include "windows.h"
#include "iostream"
using namespace std;
DWORD WINAPI Fun1Proc(LPVOID lpParameter);//声明线程入口函数
void main()
{
//创建新线程
HANDLE hThread1;
hThread1 = CreateThread(
NULL,//使用缺省的安全性
0,//初始提交的栈的大小
Fun1Proc,//线程入口函数
NULL,//传递为线程的参数
0,//附加标记 0表示线程创建后立即运行
NULL);//线程ID
//关闭线程,但不会终止新建的线程
CloseHandle(hThread1);
cout<<"main thread is running"<<endl;
Sleep(1000);//暂停主线程
/*说明:如果不添加Sleep语句,主线程会在自己的时间片中运行完成后(该时间片在main函数,也就是主线程全部执行完毕后还有时间剩余),选择直接退出,主线程都退出了,依附于主线程的新线程也就不会有机会得到执行了,只有让主线程暂停执行(采用sleep函数),即挂起,让出执行的权利,操作系统会从等待的线程中选择一个来运行,那么新创建的线程得到机会执行*/
}
DWORD WINAPI Fun1Proc(LPVOID lpParameter)
{
cout<<"thread1 is running!"<<endl;
return 0;
}
结果:孙鑫给的是main thread is running 换行 thread1 is running!
我的结果:在VC6.0下,一通乱码;在VS2008下,没出现换行
分析:估计原因出自我的本本是双核的,而VC6.0的乱码是因为装了插件缘故,不知是否是这样?
=================================================================================
添加全局变量
int index=0;
将main函数中输出语句修改为:
while(index++<50)
cout<<"main thread is running"<<endl;
将线程中输出语句修改为:
while(index++<50)
cout<<"thread1 is running!"<<endl;
将main函数中sleep语句省去
主线程和副线程在交替运行,也就是主线程在它的时间片运行结束后,副线程得到执行的权利,在它自己所对应的时间片中运行,此时主线程其实还没有运行结束,它将等待着副线程运行结束后继续执行
=================================================================================
=================================================================================
创建线程使用CreateThread:The CreateThread function creates a thread to execute within the address space of the calling process.
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, //结构体指针
DWORD dwStackSize, //指定初始提交栈的大小
LPTHREAD_START_ROUTINE lpStartAddress, //由线程执行,表示线程的起始地址,指定线程入口函数,
LPVOID lpParameter, //指定一个单独的值传递给线程
DWORD dwCreationFlags, //指定控件线程创建的附加标记
LPDWORD lpThreadId ); //指向一个用来接收线程的标识符变量
参数1 |
指向SECURITY_ATTRIBUTES结构体的指针。这里可以设置为NULL,使用默认的安全性 |
参数2 |
指定初始提交的栈的大小,即线程可以将多少地址空间用于自己的栈,以字节为单位。系统会将这个值四舍五入为最近的页面 如果该值是0或者小于缺省提交大小,则使用和调用线程一样的大小。 |
页 面 |
系统管理内存时使用的内存单位,不同的CPU其页面大小也是不同的。 X86 使用的页面大小是4KB。当保留地址空间的一个区域时,系统要确保该区域的大小是 系统的页面大小的倍数 |
参数3 |
指向LPTHREAD_START_ROUTINE(应用程序定义的函数类型)的指针。这个函数将被线程执行,表示了线程的起始地址,指定线程入口函数,该入口函数的参数类型以及返回类型要与ThreadProc()函数声明的类型要保持一致。 |
参数4 |
指定传递给线程的单独的参数的值。 |
参数5 |
指定控制线程创建的附加标记。 如果CREATE_SUSPENDED标记被指定,线程创建后处于暂停 状态不会运行,直到调用了ResumeThread函数。 如果该值是0,线程在创建之后立即运行。 |
参数6 |
[out]指向一个变量用来接收线程的标识符。创建一个线程时,系统会为线程分配一个ID号。 Windows NT/2000:如果这个参数是NULL,线程的标识符不会返回。 Windows 95/98 :这个参数不能是NULL 如果线程创建成功,此函数返回线程的句柄。 |
=================================================================================
可参考ThreadProc: DWORD WINAPI ThreadProc(LPVOID lpParameter);
=================================================================================
在主线程中创建完一个新线程之后,一般会调用CloseHandle()方法来关闭新创建的线程的句柄。
BOOL CloseHandle(HANDLE hObject);
注意:关闭句柄并没有终止新创建的线程,新建的线程继续在运行。
至于为什么要关闭线程句柄,主要有两个原因:
1.在本主线程中,这个句柄已经没什么用了。
2.当关闭线程句柄时和创建的线程执行完毕之后,系统会递减新线程的内核对象使用计数,当使用计数为0时,系统就会释放线程内核对象;
如果在主线程中没有关闭这个句柄,那么始终会保留这个引用,这样线程的内核对象的使用计数即使在创建的线程执行完毕之后也不会降为0,
因此线程的内核对象无法释放,直到进程终止时系统才会清理这些残留的对象。
所以应该在不再使用线程的句柄的时候将其关闭掉,让线程的线程内核对象的引用计数减1。
=================================================================================
当线程暂停执行的时候,也就是表示它放弃了执行的权力。
操作系统会从等待运行的线程队列中选择一个线程来运行。新创建的线程就可以得到运行的机会。
可以使用函数Sleep:
void Sleep(
DWORD dwMilliseconds //sleep time 以毫秒为单位
);
暂停当前线程指定时间间隔的执行。
=================================================================================
互斥
=================================================================================
=================================================================================
#include "iostream"
using namespace std;
#include "windows.h"
DWORD WINAPI ThreadProc1(LPVOID lpParameter);
DWORD WINAPI ThreadProc2(LPVOID lpParameter);
int ticket=50;
void main()
{
HANDLE handle1=CreateThread(NULL,0,ThreadProc1,NULL,0,NULL);
HANDLE handle2=CreateThread(NULL,0,ThreadProc2,NULL,0,NULL);
CloseHandle(handle1);
CloseHandle(handle2);
/*说明:为了使得主线程在退出之前保证副进程的执行完成,有些实现方法是采用恒真的空循环,单此种方法主线程会占用cpu的运行时间,如果采用Sleep,则主线程完全不占用cpu的任何运行时间*/
Sleep(4000);
//getchar();//VS2008
}
DWORD WINAPI ThreadProc1(LPVOID lpParameter){
//说明:在线程的时间片内持续运行
while(TRUE)
{
if(ticket>0)
cout<<"thread1 sale the ticket id is:"<<ticket--<<endl;
else
break;
}
return 0;
}
DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
while(TRUE)
{
if(ticket>0)
cout<<"thread2 sale the ticket id is:"<<ticket--<<endl;
else
break;
}
return 0;
}
<1> 孙鑫给的结果是两个线程轮流执行操作,输出结果如下,他的机子是单核的
thread1 sale the ticket id is:50
thread2 sale the ticket id is:49
。。。
<2> 我在两个线程的if语句中加入Sleep(1000);,这样能清楚的看到双核下线程的运行状况
thread1 sale the ticket id is:thread2 sale the ticket id is:5049 【双核下】【问题:同时运行,但是输出都是最后一起输出】
。。。
=================================================================================
=================================================================================
当ticket数量运行到1时,线程1正在运行,此时线程1运行到输出语句时,它的时间片已经结束,则线程1对ticket id的减减动作没有完成,此时线程2开始执行,发现数量是1,则执行减减动作,使得数量为0,返回,线程1继续执行,此时票的数量已经是0了,线程1继续执行输出语句,对票的数量执行减减,则数量变为-1,这是不允许的。这是由于抢占全局的资源所引起的。
解决这个问题的办法是实现线程间的“同步”,即一个线程在对一个全局的资源进行操作的过程中,是不允许其他线程对全局的资源进行访问,直到该线程对资源操作完毕后。
属于内核对象,它能够确保线程拥有对单个资源的互斥访问权。
互斥对象包含一个使用数量,一个线程ID和一个计数器。
ID用于标识系统中的哪个线程当前拥有互斥对象,计数器用于指明该线程拥有互斥对象的次数。
[1].CreateMutex:创建互斥对象,返回互斥对象的句柄
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes,//
BOOL bInitialOwner, // flag for initial ownership,
LPCTSTR lpName // pointer to mutex-object name
);
参数1 |
指向SECURITY_ATTRIBUTES结构体的指针。可以传递NULL,让其使用默认的安全性。 |
参数2 |
指示互斥对象的初始拥有者。 如果该值是真,调用者创建互斥对象,调用的线程获得互斥对象的所有权。 否则,调用线程捕获的互斥对象的所有权。(就是说,如果该参数为真,则调用该函数的线程拥有互斥对象的所有权。否则,不拥有所有权,当前互斥对象处于空闲状态,其他线程可以占用) |
参数3 |
互斥对象名称。传递NULL创建的就是没有名字的互斥对象,即匿名的互斥对象。 |
返回 |
创建成功之后 ,返回一个互斥对象句柄。如果一个命名的互斥对象在本函数调用之前已经存在,则返回已经存在的对象句柄。然后可以调用GetLastError检查其返回值是否为ERROR_ALREADY_EXISTS,TRUE则表示命名互斥对象已经存在,否则表示互斥对象是新创建的。 |
|
当前没有线程拥有互斥对象,操作系统会将互斥对象设置为已通知状态(有信号状态) |
[2].WaitForSingleObject:等待互斥对象的使用权,如果第二个参数设置为INFINITE,则表示会持续等待下去,直到拥有所有权,才有权执行该函数下面的语句。一旦拥有了所有权,则会将互斥对象的的线程ID设置为当前使用的线程ID值。
[3].ReleaseMutex:将互斥对象所有权进行释放,交还给系统。
//在主线程中
...
HANDLE hMutex = CreateMutex(NULL, FALSE, NULL);
...
//其他线程中
...
WaitForSingleObject(hMutex, INFINITE);
//受保护的代码
...
ReleaseMutex(hMutex);
=================================================================================
#include "iostream"
using namespace std;
#include "windows.h"
DWORD WINAPI ThreadProc1(LPVOID lpParameter);
DWORD WINAPI ThreadProc2(LPVOID lpParameter);
int ticket=50;
HANDLE hMutex;
void main()
{
HANDLE handle1=CreateThread(NULL,0,ThreadProc1,NULL,0,NULL);
HANDLE handle2=CreateThread(NULL,0,ThreadProc2,NULL,0,NULL);
CloseHandle(handle1);
CloseHandle(handle2);
//说明:为了使得主线程在退出之前保证副进程的执行完成,有些实现方法是采用恒真的空循环,单此种方法主线程会占用cpu的运行时间,如果采用Sleep,则主线程完全不占用cpu的任何运行时间
hMutex=CreateMutex(NULL,FALSE,NULL); //第二个参数为FALSE,将互斥对象声明为空闲状态
Sleep(4000);
}
DWORD WINAPI ThreadProc1(LPVOID lpParameter){
//说明:在线程的时间片内持续运行
while(TRUE)
{
WaitForSingleObject(hMutex,INFINITE); //第二个参数为INFINITE表示一直等待,直到拥有互斥对象
if(ticket>0)
{
Sleep(1);
cout<<"thread1 sale the ticket id is:"<<ticket--<<endl;
}
else
break;
ReleaseMutex(hMutex); //使用完了,将互斥对象还给操作系统
}
return 0;
}
DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
while(TRUE)
{
WaitForSingleObject(hMutex,INFINITE); //第二个参数为INFINITE表示一直等待,直到拥有互斥对象
if(ticket>0)
{
Sleep(1);
cout<<"thread2 sale the ticket id is:"<<ticket--<<endl;
}
else
break;
ReleaseMutex(hMutex); //使用完了,将互斥对象还给操作系统
}
return 0;
}
=================================================================================
=================================================================================
这是因为线程1在得到互斥对象的所有权后,进入到了循环,而释放互斥权的调用必须等到循环执行结束,即使线程1在时间片完成后,将执行权交给了线程2,单线程2发现互斥对象的所有权还是被占用着,所以没有做任何动作,线程1继续执行,直到将票销售完,退出循环,释放互斥对象的所有权,此时线程2在得到所有权后发现票已经销售一空,也就退出了。
在上述代码中,为什么输出结果正确呢,也就是线程1和2交替售票,这是因为线程1得到互斥对象的控制权后执行单张票的销售动作,动作完成就立即释放了控制权。在线程1的执行时间片完成后,线程2就开始执行了,线程2执行和线程1相同的动作。
两段代码最主要的区别就是前者在销售所有票的过程中都独占着互斥对象资源,而后者是销售完一张票后就将互斥对象资源释放掉了。
进一步:互斥对象包含一个计数器,用来记录互斥对象请求的次数, 所以在同一线程中请求了多少次就要释放多少次;
如 hMutex=CreateMutex(NULL,TRUE,NULL); //当第二个参数设置为TRUE时,互斥对象计数器设为1
WaitForSingleObject(hMutex,INFINITE); //因为请求的互斥对象线程ID与拥有互斥对象线程ID相同,可以再次请求成功,计数器加1
ReleaseMutex(hMutex); //第一次释放,计数器减1,但仍有信号
ReleaseMutex(hMutex); //再一次释放,计数器为零
即 如果操作系统发现线程已经正常终止,会自动把线程申请的互斥对象ID设为0,同时也把计数器清零,其他对象可以申请互斥对象。
可以根据WaitForSingleObject的返回值判断该线程是如何得到互斥对象拥有权的;如果返回值是WAIT_OBJECT_0,表示由于互斥对象处于有信号状态才获得所有权的;如果返回值是WAIT_ABANDONED,则表示先前拥有互斥对象的线程异常终止 或者终止之前没有调用 ReleaseMutex释放对象,此时就要警惕了访问资源有破坏资源的危险
由CreateMutex在MSDN中的介绍可以知道,只需要将该函数的第三个参数,Mutex对象的名字不设置成NULL,即给它取个名字,然后代码修改如下:
hMutex=CreateMutex(NULL,FALSE,”instance”); //Mutex的名称可以任意的取
if(hMutex)
{
if(ERROR_ALREADY_EXISTS==GetLastError())
{
cout<<"the application instance is exit!"<<endl;
return;
}
}
=================================================================================
实例:创建多线程聊天程序
=================================================================================
调用MFC的内置函数:AfxSocketInit,该函数其实也是调用Win32中的WSAStartup,并且是调用1.1的套接字库版本,该函数能确保程序终止前调用WSACleanup的调用,该函数的放置位置最好在CWinApp中的InitInstance中,注意包含头文件Afxsock.h,在StdAfx.h这个头文件中进行包含。
StdAfx.h头文件是一个预编译头文件,在该文件中包含了MFC程序运行的一些必要的头文件,如afxwin.h这样的MFC核心头文件等。它是第一个被程序加载的文件。
在CWinApp中的InitInstance添加如下代码:
if(FALSE==AfxSocketInit())
{
AfxMessageBox("套接字库加载失败!");
return FALSE;
}
在CDialog中 添加私有成员变量:SOCKET m_socket
添加成员函数:
BOOL CChatDlg::InitSocket()
{
m_socket=socket(AF_INET,SOCK_DGRAM,0); //UDP连接方式
if(INVALID_SOCKET==m_socket)
{
MessageBox("套接字创建失败!");
return FALSE;
}
SOCKADDR_IN addrServer; //将自己假想成server
addrServer.sin_addr.S_un.S_addr=htonl(INADDR_ANY);
addrServer.sin_family=AF_INET;
addrServer.sin_port=htons(1234);
int retVal;
retVal=bind(m_socket,(SOCKADDR*)&addrServer,sizeof(SOCKADDR));
if(SOCKET_ERROR==retVal)
{
closesocket(m_socket);
MessageBox("套接字绑定失败!");
return FALSE;
}
return TRUE;
}
struct RECVPARAM
{
说明:因为套接字本身只涉及传输的协议类型,是UDP还是TCP,而和是服务器端还是客户端没有必然的关系,所以本程序即涉及到服务器端又涉及到客户端,采用同一个套接字是被允许的。
SOCKET sock; //保存最初创建的套接字
HWND hWnd; //保存对话框的窗口句柄
};
在CChatDlg::OnInitDialog函数中添加下面的代码:
if(!InitSocket()) //服务器端的创建
return FALSE;
RECVPARAM *pRecvParam=new RECVPARAM;
pRecvParam->hWnd=m_hWnd;
pRecvParam->sock=m_socket;
1.接收部分应该一直处于响应状态,如果和发送部分放在同一段代码中,势必会阻塞掉发送功能的实现,所以考虑将接收放在单独的线程中,使它在一个while循环中,始终处于响应状态
2.因为需要传递两个参数进去,一个是recvfrom需要用的套接字,另一个是当收到数据后需要将数据显示在窗口中的对应文本框控件上,所以需要传递当前窗口的句柄,但CreateThread方法只能传递一个参数,即第四个参数,这时候就想到了采用结构体的方式传递。
HANDLE hThread=CreateThread(NULL,0,RecvProc,(LPVOID)pRecvParam,0,NULL);
CloseHandle(hThread);
可模仿ThreadProc的创建方式(在MSDN中有原型),但遇到一个问题,将该函数申明为CChatDlg的成员函数嘛?答案不是的,因为如果是成员函数的话,那它属于某个具体的对象,那么在调用它的时候势必要让程序创建一个对象,但该对象的构造函数有参数的话,系统就不知所措了,所以可以将函数创建为全局函数,即不属于类,但这失去了类的封装性,最好的方法是将该方法声明为静态方法,它不属于任何一个对象。
在CChatDlg类的头文件中添加:
static DWORD WINAPI RecvProc(LPVOID lpParameter);
在cpp文件中添加:
DWORD WINAPI CChatDlg::RecvProc(LPVOID lpParameter)
{
RECVPARAM* pRecvParam=(RECVPARAM*)lpParameter;
HWND hWnd=pRecvParam->hWnd;
SOCKET sock=pRecvParam->sock;
char recvBuf[200];
char resultBuf[200];
SOCKADDR_IN addrFrom; //这个时候是假想成服务器端
int len=sizeof(SOCKADDR_IN);
while(TRUE) //处于持续响应状态
{
int retVal=recvfrom(sock,recvBuf,200,0,(SOCKADDR*)&addrFrom,&len); //从客户端接收数据,并将客户端的地址结构体填充
if(SOCKET_ERROR == retVal)
{
AfxMessageBox("接收数据出错"); //因为本函数是静态函数,所以只能调用全局的消息了
break;
}
else
{
sprintf(resultBuf,"%s said:%s",inet_ntoa(addFrom.sin_addr),recvBuf);
//现在已经拿到客户端送过来的消息了,但因为自身是静态函数,所以拿不到当前窗口对象中的控件的句柄,也就不能对其赋值了,唯一办法就是用消息的形式将接收到的值抛出到窗口的消息队列中,等待消息处理
::PostMessage(hWnd,WM_RECVDATA,0,(LPARAM)resultBuf); }
}
return 0;
}
定义自定义消息的宏:
#define WM_RECVDATA WM_USER+1
声明消息响应函数:因为有参数要传递,所以wParam和lParam都要写,如果没有参数需要传递,可以不写
afx_msg void OnRecvData(WPARAM wParam,LPARAM lParam);
消息映射:
ON_MESSAGE(WM_RECVDATA,OnRecvData)
定义消息响应函数:
void CChatDlg::OnRecvData(WPARAM wParam,LPARAM lParam)
{
//注意将文本框的属性设置成多行
CString recvData=(char*)lParam;
CString temp; //文本框中现有的内容
GetDlgItemText(IDC_EDIT_RECV,temp);
temp+="\r\n";
temp+=recvData;
SetDlgItemText(IDC_EDIT_RECV,temp);
}
自此,消息的接收和显示部分已经完成了
在发送按钮点击的响应函数中添加:
DWORD dword;
CIPAddressCtrl* pIPAddr=(CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1);
pIPAddr->GetAddress(dword);
//因为对方有具体的IP地址值,我们假想对方是服务器端。在发送的时候程序就从服务器的角色转变为客户端了
SOCKADDR_IN addrServer;
addrServer.sin_addr.S_un.S_addr=htonl(dword);
addrServer.sin_family=AF_INET;
addrServer.sin_port=htons(1234);
CString strSend;
GetDlgItemText(IDC_EDIT_SEND,strSend);
sendto(m_socket,strSend,strlen(strSend)+1,0,(SOCKADDR*)&addrServer,sizeof(SOCKADDR));
SetDlgItemText(IDC_EDIT_SEND,"");
1.本程序的核心在于将消息的发送的和接收发在了两个不同的线程中,接收放在新创建的副进程中,因为其要一直处于响应状态,也就是需要一个while循环;发送放在主线程中。这样消息的接收和发送就不存在先后顺序了,且一直处于循环中的接收也不会影响到发送。
2.上述代码中的新线程入口函数中可能没有必要传递两个参数进去,其中SOCKET参数可以在入口函数内部创建,反正SOCKET变量也就是声明是TCP还是UDP,和发送或接收没有必然的联系,如果这样的话,就没有必要声明第五步中的结构体了,CreateThread方法也刚好传递一个参数,即当前窗口的句柄
Author: SKySeraph
Email/GTalk: [email protected] QQ:452728574
From: http://www.cnblogs.com/skyseraph/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,请尊重作者的劳动成果。
作者:skyseraph
出处:http://www.cnblogs.com/skyseraph/
Email/GTalk: [email protected] QQ:452728574
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。