本文目录
- 一、多线程服务器开发
- 二、TCP状态转换
- 三、端口复用
一、多线程服务器开发
服务端代码如下。
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>struct sockInfo {int fd; // 通信的文件描述符struct sockaddr_in addr; //客户端的信息pthread_t tid; // 线程号
};// 先定义好能够同时支持的客户端数量
struct sockInfo sockinfos[128];void * working(void * arg) {// 子线程和客户端通信 cfd 客户端的信息 线程号// 获取客户端的信息// 参数是void * 类型的,所以需要进行强转struct sockInfo * pinfo = (struct sockInfo *)arg;char cliIp[16];inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, cliIp, sizeof(cliIp));unsigned short cliPort = ntohs(pinfo->addr.sin_port);printf("client ip is : %s, prot is %d\n", cliIp, cliPort);// 接收客户端发来的数据char recvBuf[1024];while(1) {int len = read(pinfo->fd, &recvBuf, sizeof(recvBuf));if(len == -1) {perror("read");exit(-1);}else if(len > 0) {printf("recv client : %s\n", recvBuf);} else if(len == 0) {printf("client closed....\n");break;}write(pinfo->fd, recvBuf, strlen(recvBuf) + 1);}close(pinfo->fd);return NULL;
}int main() {// 创建socketint lfd = socket(PF_INET, SOCK_STREAM, 0);if(lfd == -1){perror("socket");exit(-1);}struct sockaddr_in saddr;saddr.sin_family = AF_INET;saddr.sin_port = htons(9999);saddr.sin_addr.s_addr = INADDR_ANY;// 绑定int ret = bind(lfd,(struct sockaddr *)&saddr, sizeof(saddr));if(ret == -1) {perror("bind");exit(-1);}// 监听ret = listen(lfd, 128);if(ret == -1) {perror("listen");exit(-1);}// 初始化数据,用整个数组的所占字节除以单个元素的大小,得到数组中的总数int max = sizeof(sockinfos) / sizeof(sockinfos[0]);for(int i = 0; i < max; i++) {//将sockinfos[i]这个地址中的所有内存大小都置为0bzero(&sockinfos[i], sizeof(sockinfos[i]));sockinfos[i].fd = -1; //-1表示是可用的文件描述符sockinfos[i].tid = -1;}// 循环等待客户端连接,一旦一个客户端连接进来,就创建一个子线程进行通信while(1) {struct sockaddr_in cliaddr;int len = sizeof(cliaddr);// 接受连接int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);// 局部变量当循环结束,就会释放,所以可以通过堆malloc来保存数据,但是子线程需要对应的去释放这个堆// 定义好结构体指针struct sockInfo * pinfo;for(int i = 0; i < max; i++) {// 从这个数组中找到一个可以用的sockInfo元素if(sockinfos[i].fd == -1) {pinfo = &sockinfos[i];break;}if(i == max - 1) {//也就是i=127的时候,sleep1秒,不然会继续下去创建子线程了sleep(1);// i--;i = -1;}}pinfo->fd = cfd;//不可以用pinfo.addr = cliaddr进行赋值,可以通过里面对应的元素进行赋值memcpy(&pinfo->addr, &cliaddr, len);// 创建子线程,第四个参数是子线程需要的参数,且类型是 (void *) 类型,但是需要cfd、客户端信息、线程号// 所以可把需要的参数封装成一个结构体,然后把结构体传进去,这里就是第四个参数pinfo// pthread_t tid得等到pthread_create之后才会有对应的值,所以可以直接用&pinfo->tid来代替,这样可以直接给结构体中的tid进行赋值pthread_create(&pinfo->tid, NULL, working, pinfo);// void* 类型的指针是一种特殊的指针类型,可以指向任何类型的对象。// 也就是可以存储任何类型的指针值,但是不能直接对他进行解引用操作。// 在进行使用的时候,必须进行强转,将其转换为正确的类型。// 不可以用pthread_join();因为是阻塞的,这样就不能等待下一个客户端进来循环了。// 设置线程分离,让当前线程结束之后自己去释放资源,不需要父线程回收。pthread_detach(pinfo->tid);}close(lfd);return 0;
}
客户端代码如下:
// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>int main() {// 1.创建套接字int fd = socket(AF_INET, SOCK_STREAM, 0);if(fd == -1) {perror("socket");exit(-1);}// 2.连接服务器端struct sockaddr_in serveraddr;serveraddr.sin_family = AF_INET;inet_pton(AF_INET, "127.0.0.1", &serveraddr.sin_addr.s_addr);serveraddr.sin_port = htons(9999);int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));if(ret == -1) {perror("connect");exit(-1);}// 3. 通信char recvBuf[1024];int i = 0;while(1) {sprintf(recvBuf, "data : %d\n", i++);// 给服务器端发送数据//这里+1 是因为要算进去字符换行的结束符,不然会有问题。write(fd, recvBuf, strlen(recvBuf)+1);int len = read(fd, recvBuf, sizeof(recvBuf));if(len == -1) {perror("read");exit(-1);} else if(len > 0) {printf("recv server : %s\n", recvBuf);} else if(len == 0) {// 表示服务器端断开连接printf("server closed...");break;}sleep(1);}// 关闭连接close(fd);return 0;
}
运行下面代码可以看到效果如图:
二、TCP状态转换
主动断开连接的一方,最后进入一个TIME_WAIT状态,这个状态是定时经过两个报文段寿命(2MSL,Maximum Segment Lifetime)之后才会结束。
这里需要搞清楚一个点,假设客户端主动断开连接,当客户端发送FIN报文之后,服务端回一个ACK,然后客户端会进入FIN_WAIT_2状态,这个时候服务端可以继续向客户端发送数据,直到发送完该发送的数据之后,才会向客户端发送FIN报文,然后客户端会进入TIME_WAIT状态,从而经过2MSL断开。
这也就是为什么是四次挥手,而不是像三次握手一样,把ACK和FIN结合起来从而整体变成三次挥手。三次握手的时候是因为双方都希望能够建立连接,所以ACK和SYN可以结合。但是断开连接可能会有某一方“不愿意”,还有需要发送的一个数据。可以理解成单方面的概念。
2MSL是为了保证安全和可靠性,因为有可能客户端回的最后一个ACK可能服务端会没收到,如果客户端立马断开,那么服务端会没断开,那么结束的状态是不完整的。没有接收到ACK,那么服务端会再次发送一个FIN,然后客户端再发一次ACK。
2MSL就是确保另外一方能够接收到ACK。Linux中msl一般是30s。
当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK后,连接的主动关闭方必须处于TIME_WAIT 状态并持续 2MSL 时间。这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK。主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号被动关闭方也不会重传),而是因为被动关闭方重传了它的FIN。事实上,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK。
有些程序就是有单方向发送的需求,所以可以用半关闭状态。
当 TCP 链接中A 向B发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN WAIT 2状态),并没有立即发送 FIN 给 A,A方处于半连接状态(半开关),此时A可以接收B发送的数据,但是 A已经不能再向B发送数据。
可以通过API来实现半连接半关闭状态。
#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd: 需要关闭的socket的描述符。
how: 允许为shutdown操作选择以下几种方式:
SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后是SHUT_WR。
使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。(在使用 fork 时,子进程会继承父进程的文件描述符,因此需要在父子进程中分别关闭不需要的文件描述符,以避免资源泄漏。)
如果有多个进程共享一个套接字,close 每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了 close,套接字将被释放。
在多进程中如果一个进程调用了 shutdown(sfd,SHUT_RDWR)后,其它的进程将无法进行通信。但如果一个进程 close(sfd)将不会影响到其它进程。
三、端口复用
在Linux中,有一些查看网络相关信息的命令。
netstat:
netstat -a :显示所有的socket
netstat -p :显示正在使用socket的程序的名称
netstat -n :直接使用IP地址,而不通过域名服务器
netstat -t :显示TCP的socket
netstat -u :显示UDP的socket
首先运行下面的server 代码。
#include <stdio.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>int main(int argc, char *argv[]) {// 创建socketint lfd = socket(PF_INET, SOCK_STREAM, 0);if(lfd == -1) {perror("socket");return -1;}struct sockaddr_in saddr;saddr.sin_family = AF_INET;saddr.sin_addr.s_addr = INADDR_ANY;saddr.sin_port = htons(9999);//int optval = 1;//setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));//int optval = 1;//setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));// 绑定int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));if(ret == -1) {perror("bind");return -1;}// 监听ret = listen(lfd, 8);if(ret == -1) {perror("listen");return -1;}// 接收客户端连接struct sockaddr_in cliaddr;socklen_t len = sizeof(cliaddr);int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);if(cfd == -1) {perror("accpet");return -1;}// 获取客户端信息char cliIp[16];inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));unsigned short cliPort = ntohs(cliaddr.sin_port);// 输出客户端的信息printf("client's ip is %s, and port is %d\n", cliIp, cliPort );// 接收客户端发来的数据char recvBuf[1024] = {0};while(1) {int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);if(len == -1) {perror("recv");return -1;} else if(len == 0) {printf("客户端已经断开连接...\n");break;} else if(len > 0) {printf("read buf = %s\n", recvBuf);}// 小写转大写for(int i = 0; i < len; ++i) {recvBuf[i] = toupper(recvBuf[i]);}printf("after buf = %s\n", recvBuf);// 大写字符串发给客户端ret = send(cfd, recvBuf, strlen(recvBuf) + 1, 0);if(ret == -1) {perror("send");return -1;}}close(cfd);close(lfd);return 0;
}
下面是client代码。
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>int main() {// 创建socketint fd = socket(PF_INET, SOCK_STREAM, 0);if(fd == -1) {perror("socket");return -1;}struct sockaddr_in seraddr;inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);seraddr.sin_family = AF_INET;seraddr.sin_port = htons(9999);// 连接服务器int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));if(ret == -1){perror("connect");return -1;}while(1) {char sendBuf[1024] = {0};fgets(sendBuf, sizeof(sendBuf), stdin);write(fd, sendBuf, strlen(sendBuf) + 1);// 接收int len = read(fd, sendBuf, sizeof(sendBuf));if(len == -1) {perror("read");return -1;}else if(len > 0) {printf("read buf = %s\n", sendBuf);} else {printf("服务器已经断开连接...\n");break;}}close(fd);return 0;
}
查看对应的端口占用情况,可以看到是server程序正在占用9999端口。
然后再运行客户端,再查看一次情况,可以看到下面的情况,有两个server。一个server是用来监听的,一个server是用来通信的(也就是状态是Established的)。
当我们主动断开服务器之后,再查看一次状态,可以看到服务器的状态还在,但是不会显示server,状态是FIN_WAIT2。并且client的状态变成了Close_WAIT。
然后再过一段时间,服务端的信息也会没有了。
那么继续刚刚的过程,运行server和client,然后退出server,再立即启动server,会发现显示端口已占用,此时查看netstat情况,会发现处于FIN_WAIT_2的一个状态。
如果继续退出client,这个时候server会从FIN_WAIT_2变成TIME_WAIT状态,然后等待2msl就会退出。
这个时候就需要进行端口复用的设置,把server端中的下面两行代码的注释取消,然后再进行尝试,就可以发现不会显示端口绑定了。
端口复用就是为了解决防止程序服务器突然重启时,之前绑定的端口还没有释放,或者程序突然退出但是没有释放端口。
int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
来看看下面这个函数的作用。
#include <sys/types.h>
#include <sys/socket.h>// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t
optlen);
setsockopt 函数用于设置套接字的选项,它允许程序员对套接字的行为进行细粒度的控制。通过指定文件描述符 sockfd,可以针对特定的套接字进行操作。level 参数指定了选项所在的协议级别,例如 SOL_SOCKET 表示在套接字层面上的选项,这通常用于设置通用的套接字行为,如端口复用等。optname 参数指定了要设置的具体选项名称,比如 SO_REUSEADDR 或 SO_REUSEPORT,这些选项分别用于控制地址和端口的复用行为,允许在某些情况下多个套接字绑定到同一个地址和端口,这对于提高服务器的并发处理能力和快速重启服务非常有用。
optval 参数是一个指向值的指针,它指定了选项的具体值,通常是一个整型值,例如 1 表示启用某个选项(如允许复用),而 0 表示禁用该选项。optlen 参数则指定了 optval 参数所指向的值的大小,这在某些情况下用于确保数据的正确传递和解析。通过这些参数的组合,setsockopt 函数能够灵活地调整套接字的行为,以满足应用程序在不同场景下的需求,比如在开发高性能网络服务器时,合理设置这些选项可以显著提升系统的性能和可靠性。
具体可以看看UNIX网络编程这本书对端口复用的解释。