TCP通信
本节代码详见https://gitee.com/hepburn0504-yyq/linux-class/tree/master/2023_04_16_TCPSocket
与UDP相比较,UDP服务端只需要调用socket函数创建套接字+bind绑定就完事了;而TCP服务端不仅要做socket和bind,还要listen和accept。
recvfrom是面向数据报的收发数据函数,仅适合UDP。TCP收发数据有两套接口可以用(read和write、send和)
因为TCP是面向连接的,所以要用listen和accept。
头文件4件套与UDP相同。
TCP是面向字节流的,读取数据有可能会不按发送格式读取,上层也不知道我这次调用read,是读到了几个TCP数据包,可能是一个,半个,也可能是好几个;而UDP是面向数据报的,每次recvfrom,一定是读的一个UDP数据包,即使留的buffer太小没读全,下次recvfrom也是读下一个数据包,前一个数据包未读到的部分就丢了。
函数
socket函数、bind函数、bzero函数与udp的使用差不多。
listen函数
功能:让TCP服务端进入监听状态
头文件
#include <sys/types.h>
#include <sys/socket.h>
原型
int listen(int sockfd, int backlog);
参数:
sockfd: 流式套接字(即文件描述符),传入socket函数的返回值
backlog: 全队列连接长度
返回值:
成功返回0,失败返回-1,会设置errno
最多允许有backlog个客户端处于连接等待状态, 如果接收到更多的连接请求就忽略。
accept函数
三次握手完成后,服务器调用accept()接受连接;如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。如果给addr 参数传NULL,表示不关心客户端的地址。
功能:让TCP服务端获取新连接,获取真正用于服务的套接字
头文件
#include <sys/types.h>
#include <sys/socket.h>
原型
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd: 套接字(即文件描述符),传入socket函数的返回值
src_addr:输出型参数,用于存储对方的ip和port
addrlen:输入输出型参数,输入的是src_addr的大小,输出实际读到的字节数
返回值:
成功返回套接字文件描述符fd,失败返回-1,会设置errno
accept返回值server_sock vs socket返回值listen_sock
该函数的返回值也是一个套接字(文件描述符),和socket函数的返回值有什么区别?
listen_sock 的功能是帮助accept函数建立底层连接(可以通过listen_sock 来获取新连接),往后真正进行服务的是accept函数的返回值server_sock 。
connect函数
客户端需要调用connect()连接服务器
头文件
#include <sys/types.h>
#include <sys/socket.h>
功能:客户端通过此函数与服务端建立连接
原型
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数
sockfd:套接字(即文件描述符),传入socket函数的返回值
buf:自己设定的缓冲区
len:sizeof(buf),缓冲区的最大字节数
flags:0表示以阻塞方式读取
src_addr:输出型参数,用于存储对方的ip和port
addrlen:输入输出型参数,输入的是src_addr的大小,输出实际读到的字节数
返回值
成功返回0,失败返回-1,errno可以查看出错信息。
connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。
ip地址转换函数
点分十进制字符串且为主机序列 转 sockaddr_in结构体且为网络字节序
头文件
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
函数原型
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
头文件
#include <arpa/inet.h>
函数原型
int inet_pton(int af, const char *src, void *dst);
sockaddr_in结构体且为网络字节序 转 点分十进制字符串且为主机序列
头文件
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
函数原型
char *inet_ntoa(struct in_addr in);
头文件
#include <arpa/inet.h>
函数原型
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块static的内存来保存ip的结果,inet_ntoa函数, 是把这个返回结果放到了静态存储区。但是这不需要调用者手动释放。该函数有静态成员会有线程安全的问题,即当多次调用时,第二次调用该函数时的结果会覆盖掉上一次的结果。
在多线程环境下,推荐使用inet_ntop,这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题。
read函数
头文件
#include <unistd.h>
功能:读取数据
原型
ssize_t read(int fd, void *buf, size_t count);
参数
fd:套接字(即文件描述符),传入socket函数的返回值
buf:自己设定的缓冲区
count:sizeof(buf),缓冲区的最大字节数
read的返回值有3种情况:
>0
两端都处于正常连接状态;==0
表示对端关闭连接;<0
表示读取数据失败
write函数
功能:写入数据
头文件
#include <unistd.h>
原型
ssize_t write(int fd, const void *buf, size_t count);
参数
fd:套接字(即文件描述符),传入socket函数的返回值
buf:自己设定的缓冲区
count:sizeof(buf),缓冲区的最大字节数
send函数
头文件
#include <sys/types.h>
#include <sys/socket.h>
功能:发送数据
原型
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数
sockfd:套接字(即文件描述符),传入socket函数的返回值
buf:自己设定的缓冲区
len:sizeof(buf),缓冲区的最大字节数
flags:0表示以阻塞方式读取
返回值与read函数的3种情况相同。
recv函数
头文件
#include <sys/types.h>
#include <sys/socket.h>
功能:发送数据
原型
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数
sockfd:套接字(即文件描述符),传入socket函数的返回值
buf:自己设定的缓冲区
len:sizeof(buf),缓冲区的最大字节数
flags:0表示以阻塞方式读取
命令
netstat
功能:显示以指定方式连接当前主机的网络通信
参数 -n 把收到的消息尽量显示为数字 -t 显示tcp形式的连接 -p 以进程形式显示 -a全部显示 -l处于listen状态的TCP服务端口
127.0.0.1 是本地环回,即两个进程用此ip进行网络通信的时候,数据只会在本地协议栈中流动,不会把数据发送到网络中。所以通常用于本地网络服务器测试。
netstat -ntpl #查看处于listen状态的TCP服务器进程
客户端实现细节
客户端建立连接过程如下
- 调用socket, 创建文件描述符;
- 调用connect, 向服务器发起连接请求;
- connect会发出SYN段并阻塞等待服务器应答; (第一次)
- 服务器收到客户端的SYN, 会应答一个SYN-ACK段表示"同意建立连接"; (第二次)
- 客户端收到SYN-ACK后会从connect()返回, 同时应答一个ACK段; (第三次)
客户端无需显示bind,也不用listen和accept。但是一定需要connect自己的port。
服务端实现版本
初始化流程如下
- 调用socket, 创建文件描述符;
- 调用bind, 将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失败;
- 调用listen, 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备;
- 调用accecpt, 并阻塞, 等待客户端连接过来;
服务端的端口号一旦确定,就不可轻易更改。
版本1:单进程循环监听
因为我们accecpt了一个请求之后,就在一直while循环尝试read和write,没有继续调用到accecpt,导致不能接受新的请求。一次只能处理一个客户端的数据,只有当前与服务端相连的客户端退出以后,才能和第二个客户端建立新的连接!
版本2:多进程+忽略SIGCHLD信号
思考:1.子进程能否打开父进程曾经创建的文件描述符呢? 2.如果父进程关闭server_sock,对子进程有影响吗?
1、子进程能否打开父进程曾经创建的文件描述符呢?可以,子进程继承父进程的数据,每个进程各自有一张文件描述符表,能看到之前的listen_sock和server_sock。所以子进程需要关闭掉自己不需要的套接字listen_sock,父进程必须关闭掉server_sock。如果父进程不关listen_sock,如果不小心修改了listen_sock,会导致服务器异常退出;如果父进程不关server_sock,就会导致父进程可用的文件描述符越来越少(一个进程能打开的 文件描述符是有限的),造成文件描述符泄漏。
2、子进程服务结束就会退出,变成僵尸状态,这个时候父进程一定要waitpid回收子进程的退出。但是waitpid函数一般都是阻塞式等待,如果使用 wait ,会导致父进程不能快速再次调用到 accept,仍然没法处理多个请求那就与单进程版本无异。(非阻塞等待比较麻烦,需要保存所有子进程的id,循环式遍历)
解决方案:由于子进程退出会给父进程发送SIGCHLD信号,那么我们就通过主动忽略SIGCHLD信号,让子进程退出的时候自动释放自己的僵尸状态。
pid_t id = fork();
assert(id != -1);
if (id == 0)
{
//子进程继承父进程的资源数据,能看到_listen_sock和server_sock
close(_listen_sock);//子进程关掉自己不需要的套接字
// 子进程服务
service(server_sock, client_ip, client_port);
exit(0); // 子进程会变成僵尸状态
}
close(server_sock);//父进程关掉自己不需要的套接字
版本3:多进程+子进程再fork
即真正为我们提供服务的是孙子进程,由于子进程退出了,那么孙子进程就变成了孤儿进程,会被OS领养,即init 1号进程会对其进行管理和资源回收。
由于子进程立马退出了,那么父进程也可以直接waitpid返回,不会阻塞等待。
pid_t id = fork();
if(id == 0)
{
close(_listen_sock);
//子进程
if(fork() > 0) exit(0);//让子进程本身退出
//让子进程的子进程就会托孤给init 1号进程
service(server_sock, client_ip, client_port);
exit(0);
}
waitpid(id, nullptr, 0);
close(server_sock);
版本4:多线程+pthread_detach
每次有一个新的连接请求,就创建一个线程来处理。
在多线程的情况下,不用关闭特定的socket文件描述符。因为线程与主线程共享一张文件描述符表。
线程一定要进行join,否则会造成内存泄漏。
解决方案:可以在每个线程的执行函数内部执行pthread_detach,进行线程分离,就无需阻塞join等待。
版本5:多线程+线程池
一般用线程池的服务器,处理业务要尽量避免常连接,比如服务器检测到在一段时间内无操作的情况下主动关闭连接。(如果从建立连接到断开连接,要一直保持连接->常连接)。
功能:大小写转换、英汉互译等
telnet工具
sudo yum install telnet -y #安装telnet工具
telnet 127.0.0.1 8080 #远程登陆工具telnet
下面是一些常用的telnet命令用法:
-
连接到远程主机:telnet <远程主机IP地址> [端口号] 例如:telnet 127.0.0.1 8080 这个命令会连接到IP地址为127.0.0.1的主机的8080端口.
-
发送命令:在连接上远程主机后,可以输入命令来执行操作。输入Ctrl+]命令,再回车,可以进入发送消息的功能。
-
退出连接:先输入Ctrl+]命令退出发送消息的功能,再输入quit,可以退出当前的telnet连接。
需要注意的是,telnet命令在现代网络中已经不太安全,因为它的数据传输是明文的,容易被黑客截获。因此,建议使用更加安全的SSH协议来进行远程登录。