C语言聊天室
基于 tcp 实现群聊功能,本项目设计是在「windows环境下基于套接字(Socket)和多线程编程」进行开发的「简易聊天室」,实现了群聊功能,在VC6.0和VS2019运行测试无误。
运行效果
聊天室
分析设计
Windows下基于windows网络接口Winsock的通信步骤为「WSAStartup 进行初始化」--> 「socket 创建套接字」--> 「bind 绑定」--> 「listen 监听」--> 「connect 连接」--> 「accept 接收请求」--> 「send/recv 发送或接收数据」--> 「closesocket 关闭 socket」--> 「WSACleanup 最终关闭」。
通信流程
了解完了一个 socket 的基本步骤后我们了解一下多线程以及线程的同步。
多线程
线程是进程的一条执行路径,它包含独立的堆栈和CPU寄存器状态,每个线程共享所有的进程资源。一个进程内的所有线程使用同一个地址空间,而这些线程的执行由系统调度程序控制,调度程序决定哪个线程可执行以及什么时候执行线程。「简而言之多线程是为了提高系统的运行效率。」
Win32 API下的多线程编程 也就是两个函数的应用创建线程CreateThread
以及等待线程结束waitForSingleObject
,具体案例这里不多做介绍。
线程的同步
每个线程都可以访问进程中的公共变量,资源,所以「使用多线程的过程中需要注意的问题是如何防止两个或两个以上的线程同时访问同一个数据,以免破坏数据的完整性」。数据之间的相互制约包括
1、「直接制约关系」,即一个线程的处理结果,为另一个线程的输入,因此线程之间直接制约着,这种关系可以称之为同步关系
2、「间接制约关系」,即两个线程需要访问同一资源,该资源在同一时刻只能被一个线程访问,这种关系称之为线程间对资源的互斥访问,某种意义上说互斥是一种制约关系更小的同步
windows线程间的同步方式有四种:「临界区、互斥量、信号量、事件。」
本项目是基于事件内核对象实现的线程同步,事件内核对象是一种抽象的对象,有受信和未授信两种状态,通过等待WaitForSingleObject
实现线程同步。事件内核对象的使用流程如下:
「创建事件内核对象」
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, //安全属性
BOOL bManualReset, //是否手动重置事件对象为未受信对象
BOOL bInitialState, //指定事件对象创建时的初始状态
LPCSTR lpName //事件对象的名称
);
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, //安全属性
BOOL bManualReset, //是否手动重置事件对象为未受信对象
BOOL bInitialState, //指定事件对象创建时的初始状态
LPCSTR lpName //事件对象的名称
);
「设置内核对象状态」
BOOL SetEvent(
HANDLE hEvent /*设置事件内核对象受信*/
);
BOOL ResetEvent(
HANDLE hEvent /*设置事件内核对象未受信*/
);
BOOL SetEvent(
HANDLE hEvent /*设置事件内核对象受信*/
);
BOOL ResetEvent(
HANDLE hEvent /*设置事件内核对象未受信*/
);
「堵塞等待事件内核对象直到事件内核对象的状态为受信」
DWORD WaitForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
DWORD WaitForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
具体使用阅读全文在我的个人网站里看,篇幅太多。
服务端设计
在创建套接字绑定监听之后会有一个等待连接的过程,在接收到新连接之后,需要创建一个线程来处理新连接,当有多个新连接时可通过创建多个线程来处理新连接,
「定义最大连接数量以及最大套接字和最大线程」
#define MAX_CLNT 256
int clnt_cnt = 0; //统计套接字
int clnt_socks[MAX_CLNT]; //管理套接字
HANDLE hThread[MAX_CLNT]; //管理线程
#define MAX_CLNT 256
int clnt_cnt = 0; //统计套接字
int clnt_socks[MAX_CLNT]; //管理套接字
HANDLE hThread[MAX_CLNT]; //管理线程
「当有新连接来临的时候创建线程处理新连接」,并将新连接添加到套接字数组里面管理
hThread[clnt_cnt] = CreateThread(
NULL, // 默认安全属性
NULL, // 默认堆栈大小
ThreadProc, // 线程入口地址(执行线程的函数)
(void*)&clnt_sock, // 传给函数的参数
0, // 指定线程立即运行
&dwThreadId); // 返回线程的ID号
clnt_socks[clnt_cnt++] = clnt_sock;
hThread[clnt_cnt] = CreateThread(
NULL, // 默认安全属性
NULL, // 默认堆栈大小
ThreadProc, // 线程入口地址(执行线程的函数)
(void*)&clnt_sock, // 传给函数的参数
0, // 指定线程立即运行
&dwThreadId); // 返回线程的ID号
clnt_socks[clnt_cnt++] = clnt_sock;
线程的处理函数ThreadProc不做过多讲解,大致就是「一个服务器,多个客户端进行数据的接收以及群发」。
主要讲解「线程同步」,当有多个新连接来临的时候,可能会造成多个线程同时访问同一个数据(例如clnt_cnt)
。这个时候就需要线程的同步来避免破坏数据的完整性
。
首先是「创建一个内核事件」
HANDLE g_hEvent; /*事件内核对象*/
// 创建一个自动重置的(auto-reset events),受信的(signaled)事件内核对象
g_hEvent = CreateEvent(NULL, FALSE, TRUE, NULL);
HANDLE g_hEvent; /*事件内核对象*/
// 创建一个自动重置的(auto-reset events),受信的(signaled)事件内核对象
g_hEvent = CreateEvent(NULL, FALSE, TRUE, NULL);
然后再需要访问连接数量clnt_cnt
这个变量之前进行「加锁(设置等待)」,访问完成之后「解锁(设置受信)」
/*等待内核事件对象状态受信*/
WaitForSingleObject(g_hEvent, INFINITE);
hThread[clnt_cnt] = CreateThread(NULL,NULL,ThreadProc,(void*)&clnt_sock,0,&dwThreadId);
clnt_socks[clnt_cnt++] = clnt_sock;
SetEvent(g_hEvent); /*设置受信*/
/*等待内核事件对象状态受信*/
WaitForSingleObject(g_hEvent, INFINITE);
hThread[clnt_cnt] = CreateThread(NULL,NULL,ThreadProc,(void*)&clnt_sock,0,&dwThreadId);
clnt_socks[clnt_cnt++] = clnt_sock;
SetEvent(g_hEvent); /*设置受信*/
通过套接字数组来进行数据的转发实现群聊功能,此时也用到了「线程同步」
void send_msg(char* msg, int len){
int i;
/*等待内核事件对象状态受信*/
WaitForSingleObject(g_hEvent, INFINITE);
for (i = 0; i send(clnt_socks[i], msg, len, 0);
SetEvent(g_hEvent); /*设置受信*/
}
void send_msg(char* msg, int len){
int i;
/*等待内核事件对象状态受信*/
WaitForSingleObject(g_hEvent, INFINITE);
for (i = 0; i send(clnt_socks[i], msg, len, 0);
SetEvent(g_hEvent); /*设置受信*/
}
客户端设计
同样也是在创建套接字连接到服务器之后,创建两个线程,一个线程实现数据的发送,一个实现数据的接收。
「发送数据到服务端」
DWORD WINAPI send_msg(LPVOID lpParam){
int sock = *((int*)lpParam);
char name_msg[NAME_SIZE + BUF_SIZE];
while (1)
{
fgets(msg, BUF_SIZE, stdin);
if (!strcmp(msg, "q\n") || !strcmp(msg, "Q\n"))
{
closesocket(sock);
exit(0);
}
sprintf(name_msg, "[%s]: %s", name, msg);
int nRecv = send(sock, name_msg, strlen(name_msg), 0);
}
return NULL;
}
DWORD WINAPI send_msg(LPVOID lpParam){
int sock = *((int*)lpParam);
char name_msg[NAME_SIZE + BUF_SIZE];
while (1)
{
fgets(msg, BUF_SIZE, stdin);
if (!strcmp(msg, "q\n") || !strcmp(msg, "Q\n"))
{
closesocket(sock);
exit(0);
}
sprintf(name_msg, "[%s]: %s", name, msg);
int nRecv = send(sock, name_msg, strlen(name_msg), 0);
}
return NULL;
}
「接收服务端数据并打印输出到显示器」
DWORD WINAPI recv_msg(LPVOID lpParam){
int sock = *((int*)lpParam);
char name_msg[NAME_SIZE + BUF_SIZE];
int str_len;
while (1)
{
str_len = recv(sock, name_msg, NAME_SIZE + BUF_SIZE - 1, 0);
if (str_len == -1)
return -1;
name_msg[str_len] = 0;
fputs(name_msg, stdout);
}
return NULL;
}
DWORD WINAPI recv_msg(LPVOID lpParam){
int sock = *((int*)lpParam);
char name_msg[NAME_SIZE + BUF_SIZE];
int str_len;
while (1)
{
str_len = recv(sock, name_msg, NAME_SIZE + BUF_SIZE - 1, 0);
if (str_len == -1)
return -1;
name_msg[str_len] = 0;
fputs(name_msg, stdout);
}
return NULL;
}
这样就不会阻塞等待终端输入之后再显示服务端发送过来的消息了。
遇到的问题
等待线程返回的过程中最先用的是WaitForSingleObject
,很遗憾这是个阻塞函数,直到线程返回才会继续往下执行,所以后面通过WaitForMultipleObjects
这个windowsAPI调用对hThread线程数组进行线程等待释放。
「缺陷:非高并发,对资源的利用不高,下周介绍Linux网络编程实现的聊天室,可能有新功能,敬请期待..」
整个过程不算太难,主要是仅实现了群聊功能,所以只需要了解windows下的网络编程以及多线程编程和线程的同步方法就可以实现这个样一个功能。