看过我的简单版的服务器代码的,会发现那段代码同一时间只能和一个客户端通信。这样的代码能力很小局限性很大。今天我来介绍一种多客户端的服务器代码。当然这段代码还是有问题的,至于是什么问题我会在代码后面说清楚。
我的这个多客户端的代码核心思想是多线程。在基本的服务器的代码中简单加一些内容就可以了。在accept的后面,我们每接到一个客户端的连接请求,就会为这个客户端创建一个单独的线程,主线程继续循环监听其他连接请求,而不用停留在那里单独为这一个客户端服务。
与此同时我们还要考虑一些其他问题,是不是每个客户端连接上来我们都要给他创建一个线程呢。显然是不能的,因为系统的socket资源是有限的,线程资源也是有限的。而且每新增一个客户端,就会增多内存的开销,我们还要考虑内存是否够用。所以要根据设备的实际能力来判断他的最大连接数量(当然如果只是为了了解原理,实现基本功能,将最大连接数设置为几百上千个对于一般机器来言是没有任何问题的)。也正是因为系统资源的宝贵,我们还要做好回收工作。比如不用的socket我们要及时close,线程结束后还要及时关闭句柄。
另外一点,我们还要掌握所有客户端线程的控制权,所以我们要有一个结构体来存储这些客户端的基本信息,包括他所占线程的ID,套接字等等以及一些实用性的属性。就好像你要写一个csdn的服务器,你不仅要记录线程id和套接字,你还要记录用户名、会员等信息。
我的多线程是用Windows的句柄创建的。但我个人建议还是使用pthread。因为C语言在linux上的使用非常广泛。用pthread写线程函数可移植性更强,跨平台的时候修改的地方更少。这里我先使用windows句柄来实现代码。以后会都使用pthread。
#include
#include
#include
#define MAX_CLIENT_NUMS 999
#pragma comment(lib,"ws2_32.lib")
typedef struct client_list_node
{
SOCKET socket_client; //客户端的socket
struct sockaddr_in c_sin; //用于存储已连接的客户端的socket基本信息
int is_run; //标记这个节点的socket是否正在被使用
HANDLE h; //为这个socket创建的线程 的句柄
}client_list_node_st, *client_list_node_t;
static client_list_node_st client_list[MAX_CLIENT_NUMS] = {0}; //客户端列表
static SOCKET socket_of_server; //服务端(本地)的socket
static struct sockaddr_in s_sin; //用于存储本地创建socket的基本信息
static int client_nums = 0; //当前连接服务器的客户端的个数
static void analysis(char* data, int datal, client_list_node_t node_t);
DWORD WINAPI myfun1(LPVOID lpParameter); //声明线程函数
int main(int argc, char* argv[])
{
int port = 6666;
int i = 0;
WORD socket_version = MAKEWORD(2, 2);
WSADATA wsadata;
if (WSAStartup(socket_version, &wsadata) != 0)
{
return 0;
}
if (argc > 1) //端口号可以通过启动参数配置,没有配置启动参数的时候默认端口6666
{
port = atoi(argv[1]);
}
socket_of_server = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//创建socket 并判断是否创建成功
if (socket_of_server == INVALID_SOCKET)
{
printf("socket error\n");
return 0;
}
s_sin.sin_family = AF_INET; //定义协议族为IPV4
s_sin.sin_port = htons(port);//规定端口号
s_sin.sin_addr.S_un.S_addr = INADDR_ANY;
/************************************************************************
s_sin.sin_addr.S_un.S_addr = INADDR_ANY; 是在设定绑定在本机的哪个IP地址上,
在哪个IP地址上进行监听。设定为INADDR_ANY代表0.0.0.0就是默认IP。
正常个人编程时 这个地方无关紧要。但若真正应用时这个地方最好设置清楚。
因为好多服务器是多个网卡,本机有多个IP。哪个网卡是连接服务所在局域网的就要
设置为哪个。
************************************************************************/
if (bind(socket_of_server, (LPSOCKADDR)&s_sin, sizeof(s_sin)) == SOCKET_ERROR)//绑定
{
printf("bind error\n");
}
if (listen(socket_of_server, 5) == SOCKET_ERROR)//监听
{
printf("listen error\n");
return 0;
}
printf("服务已经开启 PORT:%d,正在等待连接... ...\n", port);
while (1)
{
SOCKET socket_of_client; //客户端(远程)的socket
struct sockaddr_in c_sin; //用于存储已连接的客户端的socket基本信息
int c_sin_len; //函数accept的第三个参数,c_sin的大小。
c_sin_len = sizeof(c_sin);
socket_of_client = accept(socket_of_server, (SOCKADDR *)&c_sin, &c_sin_len);
/************************************************************************
没有新的连接是 程序不会一直在这里循环。此时accept会处于阻塞状态。
直到有新的连接,或者出现异常。
************************************************************************/
if (socket_of_client == INVALID_SOCKET)
{
printf("accept error\n");
continue; //继续等待下一次连接
}
else
{
if (client_nums + 1 > MAX_CLIENT_NUMS)
{
send(socket_of_client, "连接超限制,您已被断开 \n", strlen("连接超限制,您已被断开 \n"), 0);
printf("有新的客户端请求连入IP:%s PORT:%d,但由于服务器已经达到最大连接数,该设备已经被强行断开\n", inet_ntoa(c_sin.sin_addr), c_sin.sin_port);
Sleep(1000);
closesocket(socket_of_client);
continue;
}
else
{
int j = 0;
for (j = 0; j < MAX_CLIENT_NUMS; j++)
{
if (client_list[j].is_run == 0)
{
client_list[j].is_run = 1;
client_list[j].socket_client = socket_of_client;
client_list[j].c_sin;
memcpy(&(client_list[j].c_sin), &c_sin, sizeof(c_sin));
if (client_list[j].h)
{
CloseHandle(client_list[j].h);
}
client_list[j].h = CreateThread(NULL, 0, myfun1, &(client_list[j]), 0, NULL);
client_nums++;
break;
}
}
}
}
}
closesocket(socket_of_server);
WSACleanup();
return 0;
}
static void analysis(char* data, int datal, client_list_node_t node_t)
{
printf("客户端(%s:%d)发来数据:%s 数据长度:%d\n", inet_ntoa(node_t->c_sin.sin_addr), node_t->c_sin.sin_port, data, datal);
//在这里我们可以对已接收到的数据进行处理
//一般情况下这里都是处理“粘包”的地方
//解决粘包之后 将完整的数据发送给数据处理函数
}
DWORD WINAPI myfun1(LPVOID lpParameter)
{
char revData[256];//这个地方一定要酌情设置大小,这决定了每次能获取多少数据
int ret;//recv函数的返回值 有三种状态每种状态的含义在下方有解释
client_list_node_t node = (client_list_node_t)lpParameter;
printf("有新客户端连入服务器 , IP = %s PORT = %d \n", inet_ntoa(node->c_sin.sin_addr), node->c_sin.sin_port);
printf("最多可连接%d个客户端,当前已连接%d个客户端\n", MAX_CLIENT_NUMS, client_nums);
send(node->socket_client, "hello i am server \n", strlen("hello i am server \n"), 0);
while (1)
{
//接收来自 这个客户端的消息
ret = recv(node->socket_client, revData, 255, 0);
/************************************************************************
recv函数 的实质就是从socket的缓冲区里拷贝出数据
返回值就是拷贝出字节数的大小。
上面定义的载体(revData)大小是255,所以recv的第三个参数最大只能设置为255,
如果设置为大于255的数值,当执行recv函数时恰好缓冲区的内容大于255,
就会导致内存泄漏,导致ret值小于零,解除阻塞状态。因此这里最好将第三个参数
设置为revData的大小,那么当缓冲区内的数据小于255的时候
只需要执行一次recv就可以将缓冲区的内容都拷贝出来,但当缓冲区的数据大
于255的时候,就要执行多次recv函数。当缓冲区内没有内容的时候,会处于阻塞
状态,这个while函数会停在这里。直到新的数据进来或者出现异常。
************************************************************************/
if (ret > 0)
{
revData[ret] = 0x00;//正常情况下不必这么做,我这么做只是为了能按字串的形式输出它
analysis(revData, ret, node);
}
else if (ret == 0)
{
//当ret == 0 说明客户端已断开连接。这里我们直接跳出循环去等待下次连接即可。
printf("客户端断开连接, IP = %s PORT = %d \n", inet_ntoa(node->c_sin.sin_addr), node->c_sin.sin_port);
closesocket(node->socket_client);
break;
}
else//ret < 0
{
//当ret < 0 说明出现了异常 例如阻塞状态解除,或者读取数据时出现指针错误等。
//所以我们这里要主动断开和客户端的链接,然后跳出循环去等待新的连接。
printf("在与客户端通信是发生异常 IP = %s PORT = %d \n", inet_ntoa(node->c_sin.sin_addr), node->c_sin.sin_port);
closesocket(node->socket_client);
break;
}
}
node->is_run = 0;
client_nums--;
printf("最多可连接%d个客户端,当前已连接%d个客户端\n", MAX_CLIENT_NUMS, client_nums);
return;
}
上面代码亲测有效可以直接拷贝走运行,仔细看我的注释,不难理解。
当然这段代码只可以简单使用,还是有核心问题没解决的。这就涉及到互斥量的问题了。
每增加一个客户端当前的连接数量就会增加一,代码中我们会执行client_nums++,客户端断开连接时会执行client_num–。但是假如同时有很多客户单断开连接,那么这么多线程要同时访问client_nums的内存,执行自减。这就会出现意料之外的效果。这和计算机工作原理有关系,当client_nums执行自减运算的时候,系统会将变量的值取走,然后在cpu内进行计算,计算好了之后在将结果保存在client_nums的内存中。举个实际的例子来解释意外的效果是怎么产生的。假设系统当前连接客户端5个那么client_nums的值就是5,两个客户端同时断开连接,此时两个线程a和b几乎要同时执行client_nums的自减运算,理想的情况是一个线程先执行完client_nums的自减运算,另一个线程继续执行自减,client_nums就会得到理想值3,但是还有另外一种可能,线程a取走了client_nums的值,还没等运算完成将结果存到原地址内之前,线程b也取走了client_nums的值,那么线程a和线程b取走的都是5。经过线程a的计算将得到了结果为4保存在了client_nums的地址里,而线程b的运算结果也是4。此时client_nums的值就是4。但实际连接的数量只有3个。
就上述代码而言两个线程同时访问一块内存的情况还会出现在其他地方,比如当有客户端连接的时候我们需要client_nums执行自加预算,但恰好此时有客户端断开连接,那么又会出现两个线程同时访问一块内存,一个是自加,一个是自减。
基于此基础我们引入了互斥锁的概念。能很好的解决这个问题,具体内容后面的文章会解释,这里不详述。