多线程实现并发服务器

    1. #include <arpa/inet.h>
    2. #include <stdio.h>
    3. #include <unistd.h> //read write
    4. #include <string.h> // strlen
    5. #include <stdlib.h>
    6. #include <signal.h>
    7. #include <sys/wait.h>
    8. #include <errno.h>
    9. #include <pthread.h>
    10. void sig_handler(int num);
    11. void *working(void *arg);
    12. struct thread_input_param //线程的传入参数 用一个结构体封装起来
    13. { //需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号
    14. int sock_com;
    15. struct sockaddr_in client_addr;
    16. pthread_t tid;
    17. };
    18. struct thread_input_param input_param_list[128]; //规定最多只有128个客户端可以连接这个服务器
    19. int main()
    20. {
    21. //1 创建用于监听的套接字
    22. int sock_listen = socket(PF_INET, SOCK_STREAM, 0); //ipv4 AF_INET SOCK_STREAM, 0tcp协议
    23. //socket fd 并非真实文件 而是内核中的一块读写缓冲区
    24. if (sock_listen == -1)
    25. {
    26. perror("socket:");
    27. exit(-1);
    28. }
    29. //2 绑定 socket 绑定 服务器ip和端口
    30. struct sockaddr_in sa_sever;
    31. sa_sever.sin_family = AF_INET; //ipv4协议族 用AF_INET也一样
    32. inet_pton(AF_INET, "192.168.91.0", &sa_sever.sin_addr.s_addr); //网络字节序整型ip
    33. // sa_sever.sin_addr.s_addr = 0;//INADDR_ANY = 0代表 0.0.0.0代表任意地址 其实是代表服务器上的多个网卡的ip地址 通过这多个网卡地址 都能访问服务器
    34. sa_sever.sin_port = htons(9999); //网络字节序整型端口 主机字节序转网络字节序
    35. int ret = bind(sock_listen, (struct sockaddr *)&sa_sever, sizeof(sa_sever));
    36. if (ret == -1)
    37. {
    38. perror("bind:");
    39. exit(-1);
    40. }
    41. //3 监听
    42. // cat / proc / sys / net / core / somaxconn 一个端口的最大监听队列的长度
    43. // 一个端口最多可以监听多少个可能连接的客户端 4096 将传入的socket变为被动的socket(socket函数创建的都是主动型的)
    44. // 这个socket将被accept函数用于监听是否有对应的连接请求(listen不阻塞 accept阻塞)
    45. // sockfd socket函数得到的文件描述符
    46. // backlog sockfd对应的等待连接队列的最大长度
    47. // 如果客户端请求时,服务器的等待连接队列满了,客户端的connect将会得到一个ECONNERFUSED错误。
    48. // 这个最大队列长度一般设定为 未连接(未完成三次握手队列) + 已连接队列(以完成三次握手队列)
    49. // 的最大长度 未连接队列最大长度 cat / proc / sys / net / ipv4 / tcp_max_syn_backlog
    50. // 成功返回0 失败返回 - 1
    51. ret = listen(sock_listen, 128);
    52. if (ret == -1)
    53. {
    54. perror("listen:");
    55. exit(-1);
    56. }
    57. //信号回调来实现子进程资源回收!!!
    58. sigset_t set;
    59. sigemptyset(&set);
    60. sigaddset(&set, SIGCHLD); //为了防止 回调函数还没注册成功 就已经有子进程死亡了 先阻塞SIGCHLD信号
    61. sigprocmask(SIG_BLOCK, &set, NULL);
    62. //捕捉子进程死亡时发送的SIGCHILD信号
    63. //注册SIGCHLD的信号的回调函数
    64. struct sigaction act;
    65. act.sa_flags = 0; //表示使用sa_handler来进行函数回调
    66. act.sa_handler = sig_handler;
    67. sigaction(SIGCHLD, &act, NULL);
    68. sigemptyset(&act.sa_mask); //清空临时阻塞信号集表示 在执行回调函数时不阻塞任何信号
    69. //注册完回调函数后 解除SG的阻塞
    70. sigprocmask(SIG_UNBLOCK, &set, NULL);
    71. // 初始化子线程传入参数数组
    72. for (int i = 0; i < sizeof(input_param_list) / sizeof(struct thread_input_param); i++)
    73. {
    74. bzero(&input_param_list[i], sizeof(input_param_list[i])); //清0
    75. input_param_list[i].sock_com = -1; //fd初始化为-1
    76. input_param_list[i].tid = -1; //tid初始化为-1
    77. }
    78. //4 多个等待客户端连接 阻塞 一个客户端连接进来 就创建一个子线程
    79. while (1) //主线程就用来等待和连接客户端 以及回收子进程资源
    80. {
    81. struct sockaddr_in client_addr; //用于接收客户端的socket地址信息
    82. socklen_t len = (socklen_t)sizeof(client_addr);
    83. int sock_com = accept(sock_listen, (struct sockaddr *)&client_addr, &len); //返回用于和客户端通信的 sockfd
    84. //注意 等待连接的过程中 如果有信号中断了系统调用(accept) 将会产生错误accept将不再阻塞 return EINTR错误!!!
    85. if (sock_com == -1)
    86. {
    87. if (errno == EINTR) //accept被信号中断才产生的错误 不用管继续等待连接
    88. continue;
    89. perror("accept:");
    90. exit(-1);
    91. }
    92. //为新的客户端连接 创造子线程 专用于与那个客户端通信
    93. pthread_t tid;
    94. struct thread_input_param *input_param;
    95. // input_param.client_addr = client_addr; //不是c++ 结构体不能这么赋值没有重载=号 需要将每个成员.出来一个一个赋值
    96. // input_param.sock_com = sock_com;
    97. // input_param.tid = tid; //tid只在线程创建出来之后才有值这里tid赋值没有任何意义 可以直接将input_param.tid作为tid参数传入
    98. //线程的传入参数 用一个结构体封装起来
    99. //需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号
    100. //这个参数在这里传了指针给 子线程但是在当create结束后 本次循环结束 这个传入参数的临时变量就释放了
    101. //子线程中取传输参数内容 将会得到无意义内容(传入参数变为野指针了)
    102. //解决1 用malloc来创建这个input_param变量 在子线程working结束后free这块空间
    103. //解决2 创建input_param数组 规定能开子线程的数量上限 每创建一个子线程就用一个input_param[i]
    104. //寻找数组中第一个还未被使用的thread_input_param
    105. for (int i = 0; i < sizeof(input_param_list) / sizeof(struct thread_input_param); i++)
    106. {
    107. if (input_param_list[i].sock_com == -1) //=-1说明还未被使用
    108. {
    109. input_param = &input_param_list[i];
    110. break;
    111. }
    112. if (i == sizeof(input_param_list) / sizeof(struct thread_input_param) - 1)
    113. //当前已有规定上限的128个客户端连接这个服务器找不到新的输入参数给 这个新连入的客户端用
    114. {
    115. i = -1; //重新从头找 直到有空余位置给这个客户端 但是这样会导致 其他的客户端连入请求被忽略
    116. //但这样也是合理的
    117. }
    118. }
    119. input_param->sock_com = sock_com;
    120. memcpy(&input_param->client_addr, &client_addr, len); //目的 源
    121. // pthread_create(&tid, NULL, working, (void *)&input_param);
    122. pthread_create(&input_param->tid, NULL, working, (void *)input_param);
    123. pthread_detach(input_param->tid); //直接线程分离 自动回收资源
    124. }
    125. //6 关闭文件sock描述符
    126. // close(sock_com);
    127. close(sock_listen);
    128. return 0;
    129. }
    130. void *working(void *arg)
    131. { //需要传入与客户端通信用的sock_com 已经客户端信息client_addr 以及当前线程的线程号
    132. //服务器与客户端的通信逻辑
    133. //5 通信
    134. //获取客户端数据
    135. struct thread_input_param *param = (struct thread_input_param *)arg; //强制转换得到输入的参数
    136. char recvbuf[1024] = {0};
    137. //accept得到的客户端信息
    138. char client_ip[16];
    139. inet_ntop(AF_INET, &param->client_addr.sin_addr.s_addr, client_ip, sizeof(client_ip)); //网络字节序的整型 需要先转成主机字节序的字符串
    140. unsigned short client_port = ntohs(param->client_addr.sin_port); //网络字节序整型 转为主机字节序整型
    141. printf("client ip:%s port:%d\n", client_ip, client_port); //客户端因为不需要bind所以客户端的os会随机为客户端分配一个端口 通信双方不需要端口相同 端口号只是标识了是那个ip主机的哪个进程在通信
    142. while (1)
    143. {
    144. int len_read = read(param->sock_com, recvbuf, sizeof(recvbuf)); //无数据 阻塞
    145. if (len_read == -1)
    146. {
    147. //客户端退出 但是客户端退出时并未关闭该连接 服务器从该连接中读数据将会出现
    148. //read:Connection reset by peer 异常
    149. perror("read:");
    150. // 如果一端的Socket被关闭(或主动关闭,或因为异常退出而 引起的关闭),
    151. // 另一端仍发送数据,发送的第一个数据包引发该异常(Connect reset by peer)。
    152. //简单的说就是在连接断开后的读和写操作引起的。
    153. //为了避免这种错误 服务器需要检测客户端的 socket close操作 在客户端关闭连接后 服务器也立即关闭这个连接
    154. //这里还没写怎么检测客户端socket关闭
    155. exit(-1);
    156. }
    157. else if (len_read == 0)
    158. {
    159. //客户端断开连接 写端关闭 则读端读返回0
    160. printf("client closed\n");
    161. break; //客户端主动断开连接 则break 关闭这个连接
    162. }
    163. else if (len_read > 0)
    164. printf("recv from client %s\n", recvbuf);
    165. //向客户端发送数据 回射数据
    166. write(param->sock_com, recvbuf, strlen(recvbuf) + 1); //+1将/0也发送过去
    167. }
    168. //子线程结束释放(恢复)资源
    169. bzero(param, sizeof(struct thread_input_param)); //清0
    170. param->sock_com = -1; //fd初始化为-1
    171. param->tid = -1; //tid初始化为-1
    172. }
    173. void sig_handler(int num)
    174. {
    175. printf("捕获到的信号编号为:%d\n", num); //num=14 SIGALRM
    176. //回收子进程资源
    177. if (num == SIGCHLD)
    178. {
    179. //wait(NULL); //回收子进程资源
    180. //20个信号这样 并不能完全回收
    181. //当多个信号连续到来会造成后面来的信号被忽略
    182. //在执行相依信号的回调函数体时 那个信号是被阻塞的
    183. //,所以当子进程死亡 发出多个SG信号 SG信号都是未决的 只有第一个信号
    184. //(1-31)信号集无法记录目前有多少个xxx信号未决或阻塞,
    185. //只能知道目前有xxx信号未决或阻塞。---不支持排队
    186. // 从开始注册信号到注册成功这段时间里,有n个SIGCHID信号产生的话,
    187. // 那么第一个产生的SIGCHID会抢先将未决位置为1,余下的n-1个SIGCHID被丢弃,
    188. // 然后当阻塞解除之后,信号处理函数发现这时候对应信号的未决位为1,继而执行函数处理该信号,
    189. // 处理函数中的while循环顺带将其他n-1子进程也一网打尽了,在这期间未决位的状态只经历了两次变化,即0->1->0
    190. while (1) // 一次信号触发为了防止 有连续的SG信号到来 有的信号没看到
    191. //这里用个循环回收连续(很快)死亡的子进程的资源
    192. {
    193. //死循环 快速地检查是否有子进程死亡 快速地回收
    194. //回调函数 占用的是父进程的资源 相当于暂时中断 去处理这个信号 处理完回到父进程
    195. //waitpid 和 SIGCHLD 没关系,即使是某个子进程对应的 SIGCHLD 丢失了,
    196. //只要父进程在任何一个时刻调用了 waitpid,那么这个进程还是可以被回收的
    197. int ret = waitpid(-1, NULL, WNOHANG); //回收所有子进程资源 不阻塞 一次调用回收一个子进程资源
    198. if (ret > 0) //回收到了一个子进程
    199. printf("child %d die\n", ret);
    200. else if (ret == 0) //还有子进程 在运行
    201. break; //中断结束 回到父进程
    202. else if (ret == -1) //无子进程在运行
    203. break; //中断结束 回到父进程
    204. }
    205. }
    206. }


    TCP状态转换

    4 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图14 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图24 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图3
    4 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图44 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图54 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图6
    注意在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端的可能最长时间))

    半关闭、端口复用

    4 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图74 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图84 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图9
    注意在B close wait状态会向A发送剩余数据 直到B将剩余数据传完 B会主动发FIN报文 才开始断开。在B数据传输完毕前(调用close前),B处于半关闭状态。(在close之后都无法发送数据了),所以当B处于半关闭状态时A已经无法发送数据 但是A可以接收数据。

    半关闭状态(A处于FIN_WAIT_2),应用于只想要单向传输数据的时候,只允许B给A发数据 A接收B的数据其他行为都不允许,此时可以使用半关闭状态。


    使用一些API来达到半关闭状态

    1. #include <sys/socket.h>
    2. int shutdown(int sockfd,int how);
    3. //sockfd 需要关闭的socket的描述符
    4. //how 允许shutdown操作选择以下几种关闭的方式
    5. //SHUT_RD(==0) 关闭sockfd上的读功能 不允许sockfd进行读操作,即该套接字不再接收数据,
    6. //任何当前在套接字接收缓冲区的数据将被直接丢弃
    7. //SHUT_WR(==1) 关闭sockfd的写功能,不允许sockfd进行写操作,进程不能write(sockfd)
    8. //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程序突然退出而系统没有释放端口

    1. #include <sys/types.h>
    2. #include <sys/socket.h>
    3. //有些函数因为内容太多 在man文档中不会详细描述 比如下面这个函数
    4. //可以去unix网络编程这本书中查找 在第7.2有讲这个函数
    5. int setsockopt(int sockfd,int level,int optname,const void* optval,socklen_t optlen);
    6. //不仅仅能用于设置端口复用 也能设置其他的套接字属性

    sockfd 套接字描述符
    level 指定系统中解释选项的代码或为通用套接字代码,或为某个特定于协议的代码(如IPV4,IPV6,TCP) 可选参数 SOL_SOCKET IPPROTO_IP IPPROTO_IPV6 IPPROTO_TCP…
    lecvel和optname是一起使用的 在某个level下某个optname有…的设置作用。参数太多看具体的可以看上面讲的那本书,下面这张图只是其中一部分

    4 Linux网络编程5 TCP多线程并发,TCP状态转换,半关闭端口复用 - 图10


    在端口复用中 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

    1. int optval = 1;
    2. 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已被占用

    端口复用用于,在服务器主动断开连接后(服务器重启) 服务器能马上连接上之前的客户端