多线程实现并发服务器
#include <arpa/inet.h>#include <stdio.h>#include <unistd.h> //read write#include <string.h> // strlen#include <stdlib.h>#include <signal.h>#include <sys/wait.h>#include <errno.h>#include <pthread.h>void sig_handler(int num);void *working(void *arg);struct thread_input_param //线程的传入参数 用一个结构体封装起来{ //需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号int sock_com;struct sockaddr_in client_addr;pthread_t tid;};struct thread_input_param input_param_list[128]; //规定最多只有128个客户端可以连接这个服务器int main(){//1 创建用于监听的套接字int sock_listen = socket(PF_INET, SOCK_STREAM, 0); //ipv4 AF_INET SOCK_STREAM, 0tcp协议//socket fd 并非真实文件 而是内核中的一块读写缓冲区if (sock_listen == -1){perror("socket:");exit(-1);}//2 绑定 socket 绑定 服务器ip和端口struct sockaddr_in sa_sever;sa_sever.sin_family = AF_INET; //ipv4协议族 用AF_INET也一样inet_pton(AF_INET, "192.168.91.0", &sa_sever.sin_addr.s_addr); //网络字节序整型ip// sa_sever.sin_addr.s_addr = 0;//INADDR_ANY = 0代表 0.0.0.0代表任意地址 其实是代表服务器上的多个网卡的ip地址 通过这多个网卡地址 都能访问服务器sa_sever.sin_port = htons(9999); //网络字节序整型端口 主机字节序转网络字节序int ret = bind(sock_listen, (struct sockaddr *)&sa_sever, sizeof(sa_sever));if (ret == -1){perror("bind:");exit(-1);}//3 监听// cat / proc / sys / net / core / somaxconn 一个端口的最大监听队列的长度// 一个端口最多可以监听多少个可能连接的客户端 4096 将传入的socket变为被动的socket(socket函数创建的都是主动型的)// 这个socket将被accept函数用于监听是否有对应的连接请求(listen不阻塞 accept阻塞)// sockfd socket函数得到的文件描述符// backlog sockfd对应的等待连接队列的最大长度// 如果客户端请求时,服务器的等待连接队列满了,客户端的connect将会得到一个ECONNERFUSED错误。// 这个最大队列长度一般设定为 未连接(未完成三次握手队列) + 已连接队列(以完成三次握手队列)// 的最大长度 未连接队列最大长度 cat / proc / sys / net / ipv4 / tcp_max_syn_backlog// 成功返回0 失败返回 - 1ret = listen(sock_listen, 128);if (ret == -1){perror("listen:");exit(-1);}//信号回调来实现子进程资源回收!!!sigset_t set;sigemptyset(&set);sigaddset(&set, SIGCHLD); //为了防止 回调函数还没注册成功 就已经有子进程死亡了 先阻塞SIGCHLD信号sigprocmask(SIG_BLOCK, &set, NULL);//捕捉子进程死亡时发送的SIGCHILD信号//注册SIGCHLD的信号的回调函数struct sigaction act;act.sa_flags = 0; //表示使用sa_handler来进行函数回调act.sa_handler = sig_handler;sigaction(SIGCHLD, &act, NULL);sigemptyset(&act.sa_mask); //清空临时阻塞信号集表示 在执行回调函数时不阻塞任何信号//注册完回调函数后 解除SG的阻塞sigprocmask(SIG_UNBLOCK, &set, NULL);// 初始化子线程传入参数数组for (int i = 0; i < sizeof(input_param_list) / sizeof(struct thread_input_param); i++){bzero(&input_param_list[i], sizeof(input_param_list[i])); //清0input_param_list[i].sock_com = -1; //fd初始化为-1input_param_list[i].tid = -1; //tid初始化为-1}//4 多个等待客户端连接 阻塞 一个客户端连接进来 就创建一个子线程while (1) //主线程就用来等待和连接客户端 以及回收子进程资源{struct sockaddr_in client_addr; //用于接收客户端的socket地址信息socklen_t len = (socklen_t)sizeof(client_addr);int sock_com = accept(sock_listen, (struct sockaddr *)&client_addr, &len); //返回用于和客户端通信的 sockfd//注意 等待连接的过程中 如果有信号中断了系统调用(accept) 将会产生错误accept将不再阻塞 return EINTR错误!!!if (sock_com == -1){if (errno == EINTR) //accept被信号中断才产生的错误 不用管继续等待连接continue;perror("accept:");exit(-1);}//为新的客户端连接 创造子线程 专用于与那个客户端通信pthread_t tid;struct thread_input_param *input_param;// input_param.client_addr = client_addr; //不是c++ 结构体不能这么赋值没有重载=号 需要将每个成员.出来一个一个赋值// input_param.sock_com = sock_com;// input_param.tid = tid; //tid只在线程创建出来之后才有值这里tid赋值没有任何意义 可以直接将input_param.tid作为tid参数传入//线程的传入参数 用一个结构体封装起来//需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号//这个参数在这里传了指针给 子线程但是在当create结束后 本次循环结束 这个传入参数的临时变量就释放了//子线程中取传输参数内容 将会得到无意义内容(传入参数变为野指针了)//解决1 用malloc来创建这个input_param变量 在子线程working结束后free这块空间//解决2 创建input_param数组 规定能开子线程的数量上限 每创建一个子线程就用一个input_param[i]//寻找数组中第一个还未被使用的thread_input_paramfor (int i = 0; i < sizeof(input_param_list) / sizeof(struct thread_input_param); i++){if (input_param_list[i].sock_com == -1) //=-1说明还未被使用{input_param = &input_param_list[i];break;}if (i == sizeof(input_param_list) / sizeof(struct thread_input_param) - 1)//当前已有规定上限的128个客户端连接这个服务器找不到新的输入参数给 这个新连入的客户端用{i = -1; //重新从头找 直到有空余位置给这个客户端 但是这样会导致 其他的客户端连入请求被忽略//但这样也是合理的}}input_param->sock_com = sock_com;memcpy(&input_param->client_addr, &client_addr, len); //目的 源// pthread_create(&tid, NULL, working, (void *)&input_param);pthread_create(&input_param->tid, NULL, working, (void *)input_param);pthread_detach(input_param->tid); //直接线程分离 自动回收资源}//6 关闭文件sock描述符// close(sock_com);close(sock_listen);return 0;}void *working(void *arg){ //需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号//服务器与客户端的通信逻辑//5 通信//获取客户端数据struct thread_input_param *param = (struct thread_input_param *)arg; //强制转换得到输入的参数char recvbuf[1024] = {0};//accept得到的客户端信息char client_ip[16];inet_ntop(AF_INET, ¶m->client_addr.sin_addr.s_addr, client_ip, sizeof(client_ip)); //网络字节序的整型 需要先转成主机字节序的字符串unsigned short client_port = ntohs(param->client_addr.sin_port); //网络字节序整型 转为主机字节序整型printf("client ip:%s port:%d\n", client_ip, client_port); //客户端因为不需要bind所以客户端的os会随机为客户端分配一个端口 通信双方不需要端口相同 端口号只是标识了是那个ip主机的哪个进程在通信while (1){int len_read = read(param->sock_com, recvbuf, sizeof(recvbuf)); //无数据 阻塞if (len_read == -1){//客户端退出 但是客户端退出时并未关闭该连接 服务器从该连接中读数据将会出现//read:Connection reset by peer 异常perror("read:");// 如果一端的Socket被关闭(或主动关闭,或因为异常退出而 引起的关闭),// 另一端仍发送数据,发送的第一个数据包引发该异常(Connect reset by peer)。//简单的说就是在连接断开后的读和写操作引起的。//为了避免这种错误 服务器需要检测客户端的 socket close操作 在客户端关闭连接后 服务器也立即关闭这个连接//这里还没写怎么检测客户端socket关闭exit(-1);}else if (len_read == 0){//客户端断开连接 写端关闭 则读端读返回0printf("client closed\n");break; //客户端主动断开连接 则break 关闭这个连接}else if (len_read > 0)printf("recv from client %s\n", recvbuf);//向客户端发送数据 回射数据write(param->sock_com, recvbuf, strlen(recvbuf) + 1); //+1将/0也发送过去}//子线程结束释放(恢复)资源bzero(param, sizeof(struct thread_input_param)); //清0param->sock_com = -1; //fd初始化为-1param->tid = -1; //tid初始化为-1}void sig_handler(int num){printf("捕获到的信号编号为:%d\n", num); //num=14 SIGALRM//回收子进程资源if (num == SIGCHLD){//wait(NULL); //回收子进程资源//20个信号这样 并不能完全回收//当多个信号连续到来会造成后面来的信号被忽略//在执行相依信号的回调函数体时 那个信号是被阻塞的//,所以当子进程死亡 发出多个SG信号 SG信号都是未决的 只有第一个信号//(1-31)信号集无法记录目前有多少个xxx信号未决或阻塞,//只能知道目前有xxx信号未决或阻塞。---不支持排队// 从开始注册信号到注册成功这段时间里,有n个SIGCHID信号产生的话,// 那么第一个产生的SIGCHID会抢先将未决位置为1,余下的n-1个SIGCHID被丢弃,// 然后当阻塞解除之后,信号处理函数发现这时候对应信号的未决位为1,继而执行函数处理该信号,// 处理函数中的while循环顺带将其他n-1子进程也一网打尽了,在这期间未决位的状态只经历了两次变化,即0->1->0while (1) // 一次信号触发为了防止 有连续的SG信号到来 有的信号没看到//这里用个循环回收连续(很快)死亡的子进程的资源{//死循环 快速地检查是否有子进程死亡 快速地回收//回调函数 占用的是父进程的资源 相当于暂时中断 去处理这个信号 处理完回到父进程//waitpid 和 SIGCHLD 没关系,即使是某个子进程对应的 SIGCHLD 丢失了,//只要父进程在任何一个时刻调用了 waitpid,那么这个进程还是可以被回收的int ret = waitpid(-1, NULL, WNOHANG); //回收所有子进程资源 不阻塞 一次调用回收一个子进程资源if (ret > 0) //回收到了一个子进程printf("child %d die\n", ret);else if (ret == 0) //还有子进程 在运行break; //中断结束 回到父进程else if (ret == -1) //无子进程在运行break; //中断结束 回到父进程}}}
TCP状态转换
![]() ![]() ![]() |
|---|
![]() ![]() ![]() 注意在B close wait状态会向A发送剩余数据 直到B将剩余数据传完 B会主动发FIN报文 才开始断开 |
如果服务端主动关闭连接(其实就是上图的A变为服务器 B变为客户端),那么服务端就会先发送fin,最后要有个2MSL的TIME-WAIT。如果服务端在一段时间内主动关闭的连接比较多,则服务端会有大量的TIME-WAIT状态的连接要等2MSL时间,在Windows下默认为4分钟。
MSL(Maximum Segment Lifetime)最大报文段寿命
主动断开的一方最后进入一个TIME_WAIT状态,这个状态会持续2MSL,MSL官方建议2分钟,Linux中实际是30s。 在2MSL内能让TCP的主动关闭方在它最后的ACK发送丢失的情况下重发最终的ACK(当B没收收到A的这个最后的ACK=1的报文 B会重复发FIN=1 ACK=1的报文 当A重复收到这个报文 A就直到ACK发送失败了 会重发ACK并再次等待2MSL
2MSL的时间 就是A发送的ACK报文的最长寿命(A ACK报文到B端的可能最长时间) + B假如没收到A的ACK又重发的FIN=1 ACK=1报文的最长寿命(B FIN=1 ACK=1到A端的可能最长时间))
半关闭、端口复用
![]() ![]() ![]() 注意在B close wait状态会向A发送剩余数据 直到B将剩余数据传完 B会主动发FIN报文 才开始断开。在B数据传输完毕前(调用close前),B处于半关闭状态。(在close之后都无法发送数据了),所以当B处于半关闭状态时A已经无法发送数据 但是A可以接收数据。 半关闭状态(A处于FIN_WAIT_2),应用于只想要单向传输数据的时候,只允许B给A发数据 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进行写操作,进程不能write(sockfd)//SHUT_RDWR(==2) 关闭sockfd的读写功能 相当于先 SHUT_RD 再SHUT_WR
我们之前关闭连接是通过close(fd)的方式来进行的(close fd后这个fd的写和读都不能用了),但是close只是将这个fd释放掉了 减少了描述符的引用计数,并不是直接关闭连接,只有当描述符的引用计数为0时才关闭连接(看注意1)。shutdown不考虑描述符的引用计数而是直接关闭描述符,也可以选择终止一个方向的连接或只终止读或只终止写。
注意
1 如果有多个进程共享一个套接字sockfd close每被调用一次 sockfd描述符引用计数-1 当计数为0时(也就是所有子进程以及父进程都调用close sockfd后) 这个套接字才被释放(连接关闭)
2 多进程中如果一个进程调用了 shutdown(sockfd,SHUT_RDWR)后,其他的进程将无法通过sockfd进行通信,但子进程close(fd) 不会影响到其他子进程的通过sockfd进行通信
下面介绍以下端口复用,端口复用最常用的用途有 1在防止服务器重启前 存在已绑定的端口还未释放 2程序突然退出而系统没有释放端口
#include <sys/types.h>#include <sys/socket.h>//有些函数因为内容太多 在man文档中不会详细描述 比如下面这个函数//可以去unix网络编程这本书中查找 在第7.2有讲这个函数int setsockopt(int sockfd,int level,int optname,const void* optval,socklen_t optlen);//不仅仅能用于设置端口复用 也能设置其他的套接字属性
sockfd 套接字描述符
level 指定系统中解释选项的代码或为通用套接字代码,或为某个特定于协议的代码(如IPV4,IPV6,TCP) 可选参数 SOL_SOCKET IPPROTO_IP IPPROTO_IPV6 IPPROTO_TCP…
lecvel和optname是一起使用的 在某个level下某个optname有…的设置作用。参数太多看具体的可以看上面讲的那本书,下面这张图只是其中一部分
![]() |
|---|
在端口复用中 level = SOL_SOCKET optname = SO_REUSEADDR , SO_REUSEPORT
SO_REUSEADDR 用途1
在绑定一个socket之前设置了SO_REUSEADDR,除非两个socket绑定的源地址和端口号都一样,那么这两个绑定都是可行的。也许你会疑惑这跟之前的有什么不一样?关键是SO_REUSEADDR改变了在处理源地址冲突时对通配地址(“any ip address”)的处理方式。
当没有设置SO_REUSEADDR的时候,socketA先绑定到0.0.0.0:21,然后socketB绑定到192.168.0.1:21的时候将会失败(EADDRINUSE错误),因为0.0.0.0意味着”任意本地IP地址”,也就是”所有本地IP地址“,因此包括192.168.0.1在内的所有IP地址都被认为是已经使用了。但是在设置SO_REUSEADDR之后socketB的绑定将会成功,因为0.0.0.0和192.168.0.1事实上不是同一个IP地址,一个是代表所有地址的通配地址,另一个是一个具体的地址。注意上面的表述对于socketA和socketB的绑定顺序是无关的,没有设置SO_REUSEADDR,它们将失败,设置了SO_REUSEADDR,它将成功。
SO_REUSEADDR socketA socketB Result
——————————————————————————————————-
ON/OFF 192.168.0.1:21 192.168.0.1:21 Error (EADDRINUSE) ip和端口完全一样不管怎么样都无法绑定 端口不同就没关系,就是正常使用
ON/OFF 192.168.0.1:21 10.0.0.1:21 OK 不同ip同端口 ok
ON/OFF 10.0.0.1:21 192.168.0.1:21 OK
OFF 0.0.0.0:21 192.168.1.0:21 Error (EADDRINUSE)
OFF 192.168.1.0:21 0.0.0.0:21 Error (EADDRINUSE)
ON 0.0.0.0:21 192.168.1.0:21 OK
ON 192.168.1.0:21 0.0.0.0:21 OK
ON/OFF 0.0.0.0:21 0.0.0.0:21 Error (EADDRINUSE)
上面的表格假定socketA已经成功绑定,然后创建socketB绑定给定地址在是否设置SO_REUSEADDR的情况下的结果。Result代表socketB的绑定行为是否会成功。如果第一列是ON/OFF,那么SO_REUSEADDR的值将是无关紧要的。
SO_REUSEADDR 用途2
处在TIME_WAIT的socket仍然被认为绑定在源地址和端口,任何其它的试图在同样的地址和端口上绑定一个socket行为都会失败直到原来的socket真正的关闭了,这通常需要等待2MSL的时长。所以不要指望在一个socket关闭后立刻将源地址和端口绑定到新的socket上,在绝大部分情况下,这种行为都会失败。
然而,在设置了SO_REUSEADDR之后试图这样绑定(绑定相同的地址和端口)仅仅只会被忽略,而且你可以将相同的地址绑定到不同的socket上。绑定的成功与否只会检查当前bind的socket是否开启了这个标志,不会查看其它的socket。
SO_REUSEPORT允许你将多个socket绑定到相同的地址和端口只要它们在绑定之前都设置了SO_REUSEPORT。如果第一个绑定某个地址和端口的socket没有设置SO_REUSEPORT,那么其他的socket无论有没有设置SO_REUSEPORT都无法绑定到该地址和端口直到第一个socket释放了绑定。
SO_REUSEPORT并不表示SO_REUSEADDR。这意味着如果一个socket在绑定时没有设置SO_REUSEPORT,那么同预期的一样,其它的socket对相同地址和端口的绑定会失败,但是如果绑定相同地址和端口的socket正处在TIME_WAIT状态,新的绑定也会失败。当有个socket绑定后处在TIME_WAIT状态(释放时)时,为了使得其它socket绑定相同地址和端口能够成功,需要设置SO_REUSEADDR或者在这两个socket上都设置SO_REUSEPORT。当然,在socket上同时设置SO_REUSEPORT和SO_REUSEADDR也是可行的。
optval 就是要设置套接字属性的值 其类型就是上面这张标中的数据类型
对于端口复用来说 SO_REUSEADDR和SO_REUSEPORT的数据类型都是int 即为1表示可以复用为0则不能复用
optlen optval参数的大小
成功返回0 失败返回-1
int optval = 1;setsockopt(sock_listen, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)); //在绑定之前设置端口复用
端口复用一定要在绑定端口之前设置
网络相关信息的命令 netstat
-a 所有的socket (all)显示所有选项,默认不显示LISTEN相关
-t 仅显示tcp相关选项
-u 仅显示udp相关选项
-p 显示建立相关连接的程序名
-n 拒绝显示别名 能显示数字的全部转化为数字
-l 显示正在监听的
netstat -anp |grep 9999 显示端口9999的进程的相关信息
tcp 0 0.0.0.0:9999 LISTEN 5954/./server (server就为运行的可执行程序的名称)
客户端线程的ip和端口号 客户端连接的那个服务器的ip和端口号
tcp 0 127.0.0.1:36514 127.0.0.1:9999 ESTABLISHED 5959/./client
客户端或服务器如果处于TIME_WAIT状态(TIME_WAIT状态时主动关闭的一方才有)需要等待两分钟,在这两分钟内其端口还并未释放,在这期间我们想要再连接一个使用相同ip和端口的客户端或服务端 将会报address already in use已被占用
端口复用用于,在服务器主动断开连接后(服务器重启) 服务器能马上连接上之前的客户端










