重要的调试方法
查看端口号为5000的连接情况
netstat -ano | grep 5000
直接查看server程序使用的端口号及其端口号状态
netstat -anp | grep server
查看系统的一些限制
ulimit -a
查看CPU的使用情况
top
查看内存使用情况
free -m
-m:以MB为单位
主要用好socket函数。
socket提供了流(stream)和数据报(datagram)两种通信机制。
流基于TCP,数据报基于UDP。
代码
事后补充:Linux 不区分套接字文件和普通文件,使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据。
server.cpp
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<netdb.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
int main(int argc, char* argv[])
{
if (argc != 2)
{
printf("Using:./server port\nExample:./server 5005\n\n");
return -1;
}
// 第1步:创建服务端的socket。
int listenfd;
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
return -1;
}
// 第2步:把服务端用于通信的地址和端口绑定到socket上。
// “INADDR_ANY”表示任意ip地址,可以用带引号的ip地址指定特定ip,例如“192.168.137.128”
sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
// servaddr.sin_addr.s_addr = inet_addr("192.168.190.134");
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(atoi(argv[1]));
if (bind(listenfd, (sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
perror("bind");
return -1;
}
// 第3步:把socket设置为监听模式(监听模式也称为被动模式)。
if (listen(listenfd, 5) != 0)
{
perror("listen");
close(listenfd);
return -1;
}
// 第4步:接受客户端的连接。
int clientfd;
int socklen = sizeof(sockaddr_in);
sockaddr_in clientaddr;
// 如果不关心客户端信息,后2个参数可以直接设为“0”
clientfd = accept(listenfd, (sockaddr*)&clientaddr, (socklen_t*)&socklen);
printf("客户端(%s)已连接。 \n", inet_ntoa(clientaddr.sin_addr));
// 第5步:准备工作完成。与客户端通信,接收客户端发过来的报文后,回复ok。
char buffer[1024];
while (1)
{
int iret;
memset(buffer, 0, sizeof(buffer));
if ((iret = recv(clientfd, buffer, sizeof(buffer), 0)) <= 0)
{
printf("iret = %d \n", iret);
break;
}
printf("接收:%s \n", buffer);
strcpy(buffer, "ok");
if ((iret = send(clientfd, buffer, strlen(buffer), 0)) <= 0)
{
perror("send \n");
break;
}
printf("发送:%s \n", buffer);
}
// 第6步:关闭socket,释放资源。
close(listenfd);
close(clientfd);
}
client.cpp
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<netdb.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
int main(int argc, char* argv[])
{
if (argc != 3)
{
printf("Using:./client ip port\nExample:./client 127.0.0.1 5005\n\n");
return -1;
}
// 第1步:创建客户端的socket。
int sockfd;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
return -1;
}
sockaddr_in servaddr;
// 第2步:向服务器发起连接请求。
hostent* h;
if ((h = gethostbyname(argv[1])) == 0)
{
printf("gethostbyname failed. \n");
close(sockfd);
return -1;
}
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);
if (connect(sockfd, (sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
perror("connect");
return -1;
}
char buffer[1024];
// 第3步:准备工作完成。与服务端通信,发送一个报文后等待回复,然后再发下一个报文。
for (int i = 0; i < 3; ++i)
{
int iret;
memset(buffer, 0, sizeof(buffer));
sprintf(buffer, "第%d条消息,编号%03d。", i + 1, i + 1);
if ((iret = send(sockfd, buffer, strlen(buffer), 0)) <= 0)
{
perror("send");
break;
}
printf("发送:%s \n", buffer);
memset(buffer, 0, sizeof(buffer));
if ((iret = recv(sockfd, buffer, sizeof(buffer), 0)) <= 0)
{
printf("iret = %d \n", iret);
break;
}
printf("接收:%s \n", buffer);
}
// 第4步:关闭socket,释放资源。
close(sockfd);
}
socket
用于创建一个新的socket,也就是向系统申请一个socket资源。socket函数用户客户端和服务端。
int socket(int domain, int type, int protocol);
参数
domain
:协议域,又称协议族(family)。
常用的协议族有AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域Socket)、AF_ROUTE等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
《APUE》P.475
type
:指定socket类型。
常用的socket类型有SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等。
流式socket(SOCK_STREAM)是一种面向连接的socket,针对于面向连接的TCP服务应用。
数据报式socket(SOCK_DGRAM)是一种无连接的socket,对应于无连接的UDP服务应用。
protocol
:指定协议,通常为0,表示使用默认协议。当同一域
、套接字
组合的类型支持多个协议时,使用protocol
选定一个协议。
《APUE》P.475
返回值
成功则返回一个socket,失败返回-1,错误原因存于errno 中。
实际应用中
对于使用TCP的应用,第一个参数只能填AF_INET,第二个参数只能填SOCK_STREAM,第三个参数只能填0。
除非系统资源耗尽,socket函数一般不会返回失败。
!一些注意事项
标识符的分配
所有程序都会默认打开标准输入\输出\错误
3个文件,所以调用socket分派到的文件标识符从3开始。
使用gdb时,socket分派到的文件标识符从7开始。
一个程序中最多能分配1024个文件标识符(0 ~ 1024)。
可以使用ulimit -a
查看相关的限制。
[root@localhost coding]# ulimit -a
...
open files (-n) 1024
...
// 修改
[root@localhost coding]# ulimit -n 2000
标识符保留
程序结束后,原来使用的端口还有2分钟的保留时间,所以无法立即分配。(原因是TCP/IP协议的设计,具体细节忘光了)
解决方法:使用setsocket函数
int opt = 1;
unsigned int len = sizeof(opt);
setsocket(listenfd, SOL_SOCKET, SO_REUSERADDR, &opt, len);
套接字标识符的使用
套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可以接受套接字描述符。详情《APUE》P.476
shutdown
int shutdown(int sockfd, int how);
成功——返回0;失败——返回-1
how的取值
SHUT_RD:无法从该套接字读数据
SHUT_WR:无法向该套接字写数据
SHUT_RDWR:无法读写
意义:如果复制了一个套接字(例如使用dup()
),则只有当套接字的所有活动引用关闭时,close才会释放网络端点。
shutdown允许一个套接字处于不活动状态,无论引用他的文件描述符数目。
listen accept
int listen(int sockfd, int backlog);
int accept(int sockfd,struct sockaddr* addr, socklen_t* addrlen);
自己的知识结合并不是很清晰的视频得出的总结。
服务端调用listen
,将套接字设置为监听模式,然后客户端就能调用connect
连接到服务端了。
在服务端调用accept
前,客户端的请求都会保存在一个请求队列中,服务端调用accept
从队列中取出一个请求,分配一个socket与该客户端通信。然后等待下一个accept
函数调用。
所以accept
函数的调用通常也是放在while
循环体中。
补充!
accept
函数并不是接受客户端的connect请求,而是从全连接队列拿出一个已经建立好的socket,如果队列为空,则阻塞。
https://www.cnblogs.com/pengyusong/p/6434788.html
调用accept
创建了客户端套接字后,将监听套接字关闭也不会影响与客户端的通信。
三次握手与应用层的函数调用
客户端调用connect
:第一次握手开始;
已调用listen
的服务端:回应,第二次握手;
客户端:回应,第三次握手,connect
返回。
完成握手后,服务端就能调用accept
函数进行通信。
在视频后面的实践中:客户端调用connect
后,服务端就会多出一个SYN_RECV
状态的socket
连接,当服务端调用accept
后,就会有一个SYN_RECV
状态的连接转换成ESTABLISHED
。
SYN_RECV
是指,服务端被动打开后,接收到了客户端的SYN并且发送了ACK时的状态。再进一步接收到客户端的ACK就进入ESTABLISHED
状态。
listen的第2个参数
https://www.cnblogs.com/ztteng/p/5147156.html
处于SYN_RECV
状态的队列长度,默认值由一个配置文件决定:
listen
后为accept
的请求处于SYN_RECV
状态。
这个值相当于设置一个瞬间能够处理的阈值。因为通常来说listen
后会立即调用accept
。
[root@localhost ~]# cat /proc/sys/net/ipv4/tcp_max_syn_backlog
128
通过相关指令查看连接状态 netstat
// 只启动服务器
[root@localhost ~]# netstat -na | grep 5000
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN
// 用后台模式运行2次客户端
[root@localhost ~]# netstat -na | grep 5000
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:36636 127.0.0.1:5000 ESTABLISHED
tcp 0 0 127.0.0.1:5000 127.0.0.1:36634 ESTABLISHED
tcp 0 0 127.0.0.1:5000 127.0.0.1:36636 ESTABLISHED
tcp 0 0 127.0.0.1:36634 127.0.0.1:5000 ESTABLISHED
send recv
2者都能用write、read替代。最后一个参数目前都为0。细节参照《APUE》P.491、P.492
都有各自的缓冲区。
send的成功调用只表示数据已经被无错误地发送到网络驱动程序上。不能保证连接的另一端接收了数据。
分包和沾包
分包:一段完整的数据被拆成多段发送。
沾包:多段独立的数据被合并在一起。
视频中给出的解决方法是:在数据中加入数据长度,例如:发送“hello,world”
,11个字节,所以要实际发送的数据是"0011hello,world"
讲的内容很含糊,在写代码时约定好调用send、recv的长度不好吗?
!作者自定义库的解决方案
发送方
bool TcpWrite(const int sockfd, const char* buffer, const int ibuflen = 0)
{
/* 超时机制,与IO复用有关
if(sockfd == -1)
return false;
fd_set tmpfd;
FD_ZERO(&tmpfd);
FD_SET(sockfd, &tmpfd);
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
if(select(sockfd + 1, 0, &tmpfd, 0, &timeout) <= 0)
return false;
*/
int ilen = 0;
(ibuflen == 0) ? ilen = strlen(buffer) : ilen = ibuflen;
// 将长度转换为网络字节序
int ilenn = htonl(ilen);
char strTBuffer[ilen + 4];
memset(strTBuffer, 0, sizeof(strTBuffer));
// 先存入网络字节序的数据长度,再存入数据
memcpy(strTBuffer, &ilenn, 4);
memcpy(strTBuffer, buffer, ilen);
if(Writen(sockfd, strTBuffer, ilen + 4) == false)
return false;
return true;
}
接收方(有错,应该去网站下源代码)
bool TcpRead(const int sockfd, char* buffer, int* ibuflen, const int itimeout = 0)
{
if(sockfd == -1)
return false;
if(itimeout > 0)
{
}
fd_set tmpfd;
FD_ZERO(&tmpfd);
FD_SET(sockfd, &tmpfd);
struct timeval timeout;
timeout.tv_sec = itimeout;
timeout.tv_usec = 0;
if(select(sockfd + 1, 0, &tmpfd, 0, &timeout) <= 0)
return false;
// 先获取数据长度
*ibuflen = 0;
if(Readn(sockfd, (char*)ibuflen, 4) == false)
return false;
*ibuflen = ntohl(*ibuflen);
// 读取数据
if(Readn(sockfd, buffer, *ibuflen) == false)
return false;
return true;
}
Writen Readn
作者封装的2个函数,分别封装了要循环调用send和recv的操作。
用户只需要将要发送、接收的一定长度的数据地址传给函数就行了,具体的在while
循环体中调用send
、recv
的操作由函数完成。
Writen
函数的结构相同。
bool Readn(const int sockfd, const char* buffer, const size_t n)
{
int nLeft, nread, idx;
idx = 0;
// 持续要求recv函数获取nLeft长度的数据,直到读完所有数据
while(nLeft > 0)
{
if(nread = recv(sockfd, buffer + idx, nLeft, 0) <= 0)
return false;
idx += nread;
nLeft -= nread;
}
return true;
}
网络中的结构体
sockaddr_in
sockaddr_in结构体是使socket代码看起来麻烦的主要原因。
不同的通信域对应不同的地址结构,sockaddr是一个通用的地址结构。
类似于多态的特性(原来是用C语言编写的,所以没有用类和继承?),sockaddr
相当于一个基类,而用于网络通信的sockaddr_in
和用于进程间通信的sockaddr_un
就是派生类。
struct sockaddr
{
sa_family_t sin_family;// 地址类型,AF_xxx
char sa_data[14]; // 同时存放端口和ip地址
};
改进:sockaddr_in结构体,将ip地址与端口分开存储
struct sockaddr_in
{
short int sin_family; // 地址类型
unsigned short int sin_port; // 端口号
struct in_addr sin_addr; // 地址
unsighed char in_zero[8]; // 占位,为了保持与sockaddr一样的长度
};
struct in_addr
{
unsigned long s_addr;
}
例子
servaddr.sin_family = AF_INET;
// servaddr.sin_addr.s_addr = inet_addr(argv[1]);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(atoi(argv[2]));
端口号使用htons
,地址使用htonl
或inet_addr
hostent
struct hostent
{
char* h_name; // 主机名
char** h_aliases; // 主机所有别名构成的字符串数组
short h_addrtype; // 主机IP地址类型(AF_XXX)
short h_length; // 驻地IP地址长度(IPV4:4,IPV6:6)
char** h_addr_list; // 主机的ip地址,以网络字节序存储
#define h_addr h_addr_list[0];
};
gethostbyname 函数
// 客户端中对地址的设置
sockaddr_in servaddr;
hostent* h;
if ((h = gethostbyname(argv[1])) == 0)
{
printf("gethostbyname failed. \n");
close(sockfd);
return -1;
}
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);
这段代码可以直接用 servaddr.sin_addr.s_addr = inet_addr(argv[1]);
替代,但只能识别ip地址,无法识别域名。
相关函数
// 将字符串IP地址转换为32位的网络字节序IP地址
// 执行失败返回0
int inet_aton(const char* cp, struct in_addr* inp);
// 将网络字节序IP地址转换成字符串IP地址
char* inet_ntoa(struct in_addr in);
// 将字符串IP地址转换成整数,与第1个函数功能一样,参数的返回方式不同
in_addr_t inet_addr(const char* cp);
!大端序小端序
上方代码中,初始化socket参数时,要考虑到大小端转换。而使用recv/send或read/write时不需要考虑,因为通讯的字节流是以1个字节(char)为单位传输的,故不需要考虑大小端转换。
大于一个字节的数据存放:
大端序:数字的高位存低位;
小端序:数字的低位存低位。
网络字节序
TCP/IP中规定好的一种数据表示格式,独立于具体的机器、操作系统等,从而保证不同主机之间能正确解释数据。
网络字节序采用大端序。
主机字节序
由具体的CPU设计决定,与OS无关。
为了实现不同主机之间的通信,要采用一种共通的字节序,这就是网络字节序。
即使是同一台机器上的2个进程,也要考虑字节序问题。(Java的JVM采用大端序)
网络字节序与主机字节序之间的转换函数:
htons()
、ntohs()
、htonl()
、ntohl()
host to network 、network to host
short、long
TCP协议中的主机地址和端口用整数表示
将ip地址转为二进制,将4个二进制数作为一个整体转为十进制,得到的整数就是TCP表示的地址。
192.168.190.134
11000000 10101000 10111110 10000110
3232284294
htonl(3232284294)
返回的结果:
10000110 10111110 10101000 11000000
2260641984
多进程服务端
使用作者封装的CTcpServer类
重点就是fork的使用:父进程生成子进程后继续等待连接;子进程则往下执行消息处理代码。
int main()
{
for(int i = 0; i < 10; ++i)
signal(i, SIG_IGN);
if(g_TcpServer.InitServer(5000) == false)
{
printf("服务端初始化失败,程序退出。\n");
return -1;
}
while(1)
{
if(g_TcpServer.Accept() == false)
continue;
// 如果是父进程调用fork,结束此次循环,进入accept阻塞
// 如果是子进程调用fork,执行下方的消息处理代码
if(fork() > 0)
continue;
printf("与客户端通信的线程已创建。\n");
// 下方代码是基本上是从多线程服务器代码中的线程函数中搬来的
// 修改部分:因为使用的是多进程,所以可以直接使用g_TcpServer的成员变量保存的客户端端口号
char strbuffer[1024];
while(1)
{
memset(strbuffer, 0, sizeof(strbuffer));
if(g_TcpServer.Recv(strbuffer,300) == false)
break;
printf("接收:%s \n", strbuffer);
strcpy(strbuffer, "ok");
strcpy(strbuffer, "ok");
if(g_TcpServer.Send(strbuffer,300) == false)
break;
printf("发送:%s \n", strbuffer);
}
printf("客户端已断开连接。\n");
exit(0);
}
}
僵尸进程的处理
当一个进程终止时,OS会释放资源,但它位于进程表中的条目还存在,直到它的父进程调用wait();这是因为进程表包含了进程的退出状态。
进程已终止,但其父进程还未调用wait(),这种状态的进程被称为僵尸进程(zombie process)。所有进程终止时都会过渡到这种状态。在父进程调用了wait()后,僵尸进程的进程标识符和它在进程表中的条目都会被释放。
如果父进程没有调用wait()就终止,使得子进程称为孤儿进程(orphan process),Linux和UNIX的处理方法是: init进程定期调用wait()收集孤儿进程的退出状态,并释放最后的进程残留。
// 后面跟有“<defunct>”的就是僵尸进程
[root@localhost coding]# ps -e | grep server
2873 pts/0 00:00:00 server
2875 pts/0 00:00:00 server <defunct>
2877 pts/0 00:00:00 server
[root@localhost coding]# ps -e | grep server
2873 pts/0 00:00:00 server
2875 pts/0 00:00:00 server <defunct>
2877 pts/0 00:00:00 server <defunct>
解决方法:屏蔽子进程的退出信号
int main()
{
signal(SIGCHLD, SIG_IGN);
...
}
插入这行代码后,子进程终止就不会产生僵尸进程了
关闭多余的socket
在上述多进程服务器代码中,CTcpServer类保存有2个socker,一个是监听socket,一个是客户socket。
父进程不需要与客户端通信,子进程不需要监听操作,2者都要关闭一个socket。
解决方法:调用close
函数关掉就好了。父进程关客户端socket,子进程关监听socket。
int main()
{
...
if(fork() > 0)
{
close(g_TcpServer.m_clientfd);
continue;
}
close(g_TcpServer.m_listenfd);
...
}
多进程服务器的退出和资源释放
int main()
{
for(int i = 0; i < 100; ++i)
signal(i, SIG_IGN);
signal(SIGINT, ParentEXIT);
signal(SIGTERM, ParentEXIT);
...
if(fork() > 0)
continue;
signal(SIGINT, ChildEXIT);
signal(SIGTERM, ChildEXIT);
...
}
void ParentEXIT(int sig)
{
// 屏蔽退出信号,避免善后工作被打扰
if(sig > 0)
{// 除了2个终止信号,还要屏蔽自身,免得反复调用
signal(sig, SIG_IGN);
signal(SIGINT, SIG_IGN);
signal(SIGTERM, SIG_IGN);
}
// 通知子进程退出
kill(0, 15);
printf("父进程退出。 \n");
// TCPServer类的成员函数,负责善后(释放资源、提交、回滚事务)
TCPServer.CloseClient();
}
// 子进程的退出处理函数就只是少了“kill(0, 15);”而已,其余完全一致
多进程服务器日志
每次服务器运行都打开一个日志文件,收发信息时都额外将信息写入作者封装号的日志文件。
通常日志文件数据开头都还带有时间。
http://www.freecplus.net/d2e2e42fa0014d04922c64b3104a0b7d.html
?增加业务逻辑
https://www.bilibili.com/video/BV11Z4y157RY?p=26
添加了用户认证功能。使用XML格式进行数据交换。
TCP短连接与长连接
短连接
连接双方只进行一次或连续多次通信,通信完成后立即断开。管理简单,不需要额外控制手段。
长连接
双方进行多次通信,通信的频率和次数不确定,需要保持这个连接。
二者没有优劣分别,根据不同场景采取不同机制。
心跳机制(保活机制)
客户端服务端通常通过心跳机制保持TCP长连接。
采用长连接的TCP连接,在连接空闲时客户端每隔一段时间(60s内,不超过120s)向服务端发送一个心跳报文,服务端也进行回复,确认连接仍生效。
如果服务端在约定时间内没有收到客户端的报文,则认为客户端已掉线,主动断开连接,释放资源。
心跳机制要程序员在应用层实现。
视频作者封装了一个Read函数,直接将超时时间设为60s。
以上是在应用层实现。
实际上TCP协议也提供了相关机制,可以通过应用层的代码进行设置。
man 7 tcp
进入tcp
手册,搜索tcp_keepalive
字段可以看到相关参数
tcp_keepalive_intvl ...
tcp_keepalive_probes ...
tcp_keepalive_time ...
默认配置值在/proc/sys/net/ipv4
目录下可以找到,就是一个属性对应一个文件
ls /proc/sys/net/ipv4/
这样就可以修改默认值了。
多线程服务端
出现了一堆新的线程函数。
pthread_cleanup_push();
pthread_detach();
pthread_self();
pthread_setcanceltype();
pthread_cleanup_pop();
需要另一个视频合集的知识:
https://www.bilibili.com/video/BV1zf4y1Q7Nj?p=1
退出函数(通过信号2和信号15触发)
void mainexit(int sig)
{
// 作者的日志函数
logfile.Write("mainexit begin. \n");
// 关闭监听socket
TcpServer.CloseListen();
// vpthid是个vector容器,元素是线程id
for(int i = 0l i < vpthid.size(); ++i)
{
logfile.Write("cancel %ld \n", vpthid[i]);
pthread_cancel(vpthid[i]);
}
logfile.Write("mainexit end. \n");
exit(0);
}
网络服务端性能测试
服务端性能指标是面试中必问的。
主要的性能指标:
- 服务端并发能力(同时响应多少个连接)
- 服务端业务处理能力(同时响应多少个业务)
- 客户端业务响应时效()
- 网络带宽
配合ps、top等指令。
在测试并发性能时,主要是为了测试服务端的连接能力,所以客户端业务请求不要太频繁,让服务端专注于处理建立TCP连接。
通过脚本运行多个客户端。
测试业务性能时,同类,客户端的数量不要太多。
通过修改客户端代码,在短时间内发起多次请求。
获取1秒内响应的客户端消息的数量:
grep "2021-06-02 10:02:34 接收" (日志文件目录) | wc
得到的结果就是统计日志文件中拥有"2021-06-02 10:02:34 接收"
字段的行数。
IO复用
多进程/线程并发模型,是为每个socket分配一个进程/线程;
IO复用能让单个进程/线程管理多个socket。
3种I/O复用方案:select、poll、epoll