作者:@阿亮joy.
专栏:《学会Linux》
座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
在揭开套接字编程神秘面纱(上)中,我们已经学习到了套接字编程的相关基础知识以及编写了基于 UDP 协议的 echo 服务器、指令服务器和简易版的公共聊天室等,那么我们现在就来学习基于 TCP 协议的套接字编程。
TcpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "Log.hpp"
#define SIZE 1024
static void Service(int sock, const std::string& clientIP, uint16_t clientPort)
{
// Echo Server
char buffer[SIZE];
while(true)
{
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s - 1] = '\0';
std::cout << clientIP << " : " << clientPort << "#" << buffer << std::endl;
}
else if(s == 0) // 对端关闭连接
{
logMessage(NORMAL, "%s:%d quit, me too!", clientIP.c_str(), clientPort);
break;
}
else
{
logMessage(FATAL, "Read Fail, Errno:%d, Strerror:%s", errno, strerror(errno));
break;
}
// 将消息发回去
write(sock, buffer, strlen(buffer));
}
}
class TcpServer
{
private:
const static int backlog = 20; // 全队列长度
public:
TcpServer(uint16_t port, std::string ip = "")
: _port(port)
, _ip(ip)
, _listenSock(-1)
{}
void InitServer()
{
// 1. 创建套接字:SOCK_STREAM面向字节流
_listenSock = socket(AF_INET, SOCK_STREAM, 0);
if(_listenSock < 0)
{
logMessage(FATAL, "Create Socket Fail! Errno:%d Strerror:%s", errno, strerror(errno));
exit(2);
}
logMessage(NORMAL, "Create Socket Success! _sock:%d", _listenSock);
// 2. 绑定套接字
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
local.sin_port = htons(_port);
// 绑定套接字失败
if(bind(_listenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "Bind Socket Fail! Errno:%d Strerror:%s", errno, strerror(errno));
exit(3);
}
// 3. 因为TCP是面向连接的,那么正式进行网络通信时,先需要建立连接
if(listen(_listenSock, backlog) < 0)
{
logMessage(FATAL, "Listen Socket Fail! Errno:%d Strerrno:%s", errno, strerror(errno));
exit(4);
}
logMessage(NORMAL, "Init Server Success!");
}
void StartServer()
{
while(true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
// accept函数的返回值是文件描述符,它用于后续的网络通信
// 而_sock只用于获取新连接,并不用于后续的网络通信
// 注:accept是阻塞等待新连接的到来
int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0)
{
logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
continue;
}
// 获取连接成功,开始进行网络通信
uint16_t clientPort = ntohs(src.sin_port);
std::string clientIP = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
// 单进程循环版
Service(serviceSock, clientIP, clientPort);
close(serviceSock);
}
}
~TcpServer()
{
if(_listenSock >= 0)
close(_listenSock);
}
private:
uint16_t _port;
std::string _ip;
int _listenSock;
};
注:日志组件的代码在揭开套接字编程的神秘面纱(上)一文中可以找到!
listen 函数的详细介绍
int listen(int sockfd, int backlog);
查看网络状态
Telnet 协议
Telnet 是一种用于在互联网上进行远程登录的协议,也是一种基于文本的协议,其运行在 TCP /I P 协议上。telnet命令是一种用于测试网络连接性和调试网络问题的工具,同时也可以用于远程登录到另一个计算机。
在使用 telnet 命令时,可以通过以下语法来调用它:
telnet [选项] [主机名或IP地址] [端口号]
其中,主机名或 IP 地址指定要连接的远程主机名或 IP 地址,端口号指定要连接的远程端口。如果未指定端口号,则默认使用 23 端口(Telnet 服务端口)。
使用 telnet 命令时,可以先输入 telnet 命令并指定要连接的主机名和端口号。如果连接成功,将会看到远程主机上的欢迎信息。按下组合键 Ctrl + ],再按下回车键,此时就看输入信息发送给服务端了。在 telnet 会话中,可以通过输入命令来与远程主机进行交互,就像在本地终端上一样。要退出 telnet 会话,需要按下组合键 Ctrl + ],然后输入 quit 命令。
需要注意的是,由于 Telnet 协议是明文传输,不提供任何加密和安全机制,因此使用telnet进行远程登录并不安全。为了保护数据的机密性和完整性,应该使用更加安全的协议,例如 SSH(Secure Shell)协议。
因为现在服务器是单进程的,所以当有两个连接来了时,服务器只能处理一个连接,并且要当该连接关闭才能处理下一个链接!
TcpServer.cc
#include "TcpServer.hpp"
#include
static void Usage(std::string proc)
{
std::cout << "\nUsage: " << proc << " Port" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = atoi(argv[1]);
std::unique_ptr<TcpServer> ptr(new TcpServer(port));
ptr->InitServer();
ptr->StartServer();
return 0;
}
因为单进程版的 echo 服务器只能处理一个客户端的链接,那么我们就将其改写成多进程版。
多进程版的 TCP 服务器中,主进程(父进程)会接收客户端的连接请求,然后创建一个新的子进程来处理连接。在子进程中,会执行 TCP 通信的相关操作。当子进程处理完请求前,需要关闭不需要的文件描述符,以释放资源并确保安全性。
在多进程环境下,每个进程都有自己的文件描述符表,如果不关闭不需要的文件描述符,则可能会导致资源泄漏和安全问题。例如,一个子进程可能会在某个文件上持续进行读取操作,但是在父进程中却没有这个需要读取的文件,如果不关闭该文件描述符,则会造成资源浪费和潜在的安全问题。
因此,在多进程版的 TCP 服务器中,父进程和子进程需要各自关闭自己不需要的文件描述符,以确保每个进程都能够释放资源并保证程序的安全性。这样做可以提高程序的效率和稳定性,避免出现资源竞争和其他问题。
void StartServer()
{
// 主动忽略SIGCHLD信号,子进程退出的时候会自动释放自己的僵尸状态
signal(SIGCHLD, SIG_IGN);
while(true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
// accept函数的返回值是文件描述符,它用于后续的网络通信
// 而_sock只用于获取新连接,并不用于后续的网络通信
// 注:accept是阻塞等待新连接的到来
int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0)
{
logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
continue;
}
// 获取连接成功,开始进行网络通信
uint16_t clientPort = ntohs(src.sin_port);
std::string clientIP = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
pid_t id = fork();
assert(id != -1);
(void)id;
if(id == 0)
{
// 子进程会继承父进程文件描述符表
// 子进程不需要关心监听套接字
close(_listenSock);
Service(serviceSock, clientIP, clientPort);
exit(0);
}
// 父进程不需要关系用于提供服务的套接字
close(serviceSock);
}
}
为什么多个子进程所用于通信的套接字(文件描述符)都是相等的呢?因为父进程会关闭自己所不需要的文件描述符,这个不需要的文件描述符就是 4,所以每次用于网络通信的文件描述符都是 4。
多进程的改进版
void StartServer()
{
while(true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0)
{
logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
continue;
}
// 获取连接成功,开始进行网络通信
uint16_t clientPort = ntohs(src.sin_port);
std::string clientIP = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
pid_t id = fork();
if(id == 0)
{
// 子进程
close(_listenSock);
if(fork() > 0) exit(0); // 子进程本身立即退出
// 因为子进程退出了,那么孙子进程就会北城孤儿进程被1号进程
// 领养,让操作系统自动释放孙子进程的僵尸状态
Service(serviceSock, clientIP, clientPort);
exit(0);
}
// 父进程
close(serviceSock);
waitpid(id, nullptr, 0); // 此时的waitpid不会阻塞太久
}
}
TcpClient.cc
#include
#include
#include
#include
#include
#include
#include
#include
static void Usage(std::string proc)
{
std::cout << "\nUsage:" << proc << "serverIP serverPort" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
uint16_t sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
std::cerr << "Create Socket Fail!" << std::endl;
exit(2);
}
std::string serverIP = argv[1];
uint16_t serverPort = atoi(argv[2]);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
server.sin_addr.s_addr = inet_addr(serverIP.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
{
std::cerr << "Connet Fail!" << std::endl;
exit(3);
}
std::cout << "Connet Success!" << std::endl;
while(true)
{
std::string message;
std::cout << "Please Enter Your Message: ";
std::getline(std::cin, message);
send(sock, message.c_str(), message.size(), 0);
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if(s > 0)
{
buffer[s] = 0;
std::cout << "server# " << buffer << std::endl;
}
else if(s == 0)
break;
else
break;
}
close(sock);
return 0;
}
TCP 客户端端口号的绑定问题
当客户端程序调用 connect 系统调用时,内核会为客户端分配一个临时的、未绑定的端口号,并将其绑定到客户端套接字描述符对应的网络地址上。需要注意的是,如果客户端希望绑定特定的端口号,可以在调用 connect 之前使用 bind 系统调用来指定端口号。但是,这种情况比较少见,通常情况下客户端会使用动态分配的端口号。
send、recv 和 sendto、recvfrom 的区别
多线程版需要注意的细节:
class ThreadData
{
public:
uint16_t _port;
std::string _ip;
int _sock;
};
class TcpServer
{
static void* threadRoutine(void* args)
{
// 线程分离,主线程不行关心其退出状态
pthread_detach(pthread_self());
ThreadData* td = (ThreadData*)args;
Service(td->_sock, td->_ip, td->_port);
delete td;
return nullptr;
}
public:
void StartServer()
{
while(true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
// accept函数的返回值是文件描述符,它用于后续的网络通信
// 而_sock只用于获取新连接,并不用于后续的网络通信
// 注:accept是阻塞等待新连接的到来
int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0)
{
logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
continue;
}
// 获取连接成功,开始进行网络通信
uint16_t clientPort = ntohs(src.sin_port);
std::string clientIP = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
// 子线程不能关闭文件描述符,因为多线程场景下文件描述符是公用的
ThreadData* td = new ThreadData();
td->_sock = serviceSock;
td->_port = clientPort;
td->_ip = clientIP;
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, td);
}
}
};
本篇博客使用的线程池相较于线程池的实现,有略微的改动。主要改动如下:类型的重命名,将 Thread.hpp 中的
typedefvoid*(*func_t)(void*)
改成typedefvoid*(*Func_t)(void*)
,以避免与 Task.hpp 中的 func_t 产生命名冲突。还有改动就是将任务类的 Excute 函数改成了 operator(),并给任务类多加了一下成员变量。
任务类
#pragma once
#include
#include
#include
using func_t = std::function<void(int, const std::string&, const uint16_t&, const std::string&)>;
// 任务类
class Task
{
public:
Task() = default;
Task(int sock, const std::string& ip, uint16_t port, func_t func)
: _sock(sock)
, _ip(ip)
, _port(port)
, _func(func)
{}
void operator()(std::string& name)
{
_func(_sock, _ip, _port, name);
}
private:
int _sock;
std::string _ip;
uint16_t _port;
func_t _func;
};
class TcpServer
{
private:
const static int backlog = 20; // 全队列长度
public:
TcpServer(uint16_t port, std::string ip = "")
: _port(port)
, _ip(ip)
, _listenSock(-1)
, _ptr(ThreadPool<Task>::getThreadPool())
{}
// ...
void StartServer()
{
_ptr->Run();
while(true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
// accept函数的返回值是文件描述符,它用于后续的网络通信
// 而_sock只用于获取新连接,并不用于后续的网络通信
// 注:accept是阻塞等待新连接的到来
int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0)
{
logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
continue;
}
// 获取连接成功,开始进行网络通信
uint16_t clientPort = ntohs(src.sin_port);
std::string clientIP = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
Task t(serviceSock, clientIP, clientPort, Service);
_ptr->Push(t);
}
}
// ...
private:
// ...
std::unique_ptr<ThreadPool<Task>> _ptr;
};
关于线程池版的 echo 服务器,需要注意一下几点:
在 Linux 操作系统中,有一些用于进行地址转换的函数,主要用于处理网络通信中的地址格式转换。以下是一些常用的 Linux 网络通信中的地址转换函数:
inet_aton 和 inet_addr: 这两个函数用于将点分十进制表示的 IPv4 地址转换为网络字节序的二进制表示。inet_aton 将 IPv4 地址转换为 struct in_addr 类型的结构体,而 inet_addr 则将 IPv4 地址转换为 32 位无符号整数。
inet_ntoa:这个函数用于将网络字节序的二进制表示的 IPv4 地址转换为点分十进制表示的字符串形式。
inet_pton 和 inet_ntop: 这两个函数用于进行 IPv4 和 IPv6 地址之间的二进制表示和文本表示之间的转换。inet_pton 将 IPv4 或 IPv6 地址的字符串表示转换为对应的二进制表示,存储在指定的结构体中。inet_ntop 则将二进制表示的 IPv4 或 IPv6 地址转换为对应的文本表示。
inet_aton 和 inet_ntoa 函数的使用
#include
#include
#include
#include
int main()
{
struct sockaddr_in addr;
inet_aton("127.0.0.1", &addr.sin_addr);
uint32_t* ptr = (uint32_t*)&addr.sin_addr;
printf("addr: %x\n", *ptr);
printf("addr_str: %s\n", inet_ntoa(addr.sin_addr));
return 0;
}
inet_pton 和 inet_ntop 函数的使用
#include
#include
int main()
{
char ip_addr[] = "127.0.0.1";
struct in_addr addr;
// 将字符串形式的IPv4地址转换为二进制形式,并存储到addr中
if (inet_pton(AF_INET, ip_addr, &addr) <= 0)
{
printf("Invalid IP address\n");
return -1;
}
// 输出二进制形式的IP地址
printf("Binary IP address: 0x%x\n", addr.s_addr);
return 0;
}
#include
#include
int main()
{
struct sockaddr_in sa;
char buffer[INET_ADDRSTRLEN];
// 设置IPv4地址
sa.sin_family = AF_INET;
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
// 将二进制格式IP地址转换为字符串格式
const char *ip = inet_ntop(AF_INET, &(sa.sin_addr), buffer, INET_ADDRSTRLEN);
printf("IP地址:%s\n", buffer);
return 0;
}
关于 inet_ntoa 函数
inet_ntoa 这个函数返回了一个 char*,很显然是这个函数自己在内部为我们申请了一块内存来保存 IP 的结果. 那么是否需要调用者手动释放呢?
man 手册上说,inet_ntoa 函数是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放。那么问题来了,如果我们调用多次这个函数,会有什么样的效果呢?参见如下代码:
#include
#include
#include
int main()
{
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
char* ptr1 = inet_ntoa(addr1.sin_addr);
char* ptr2 = inet_ntoa(addr2.sin_addr);
printf("ptr1:%s ptr2:%s\n", ptr1, ptr2);
return 0;
}
因为 inet_ntoa 把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果。
测试代码:
#include
#include
#include
#include
#include
#include
void* Func1(void* p)
{
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1)
{
char* ptr = inet_ntoa(addr->sin_addr);
printf("addr1: %s\n", ptr);
sleep(1);
}
return NULL;
}
void* Func2(void* p)
{
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1)
{
char* ptr = inet_ntoa(addr->sin_addr);
printf("addr2: %s\n", ptr);
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid1 = 0;
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
pthread_create(&tid1, NULL, Func1, &addr1);
pthread_t tid2 = 0;
pthread_create(&tid2, NULL, Func2, &addr2);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
TCP(Transmission Control Protocol)协议是一种面向连接的、可靠的、基于字节流的传输协议,其通讯流程如下:
服务器初始化:
建立连接
数据传输:
断开连接:
建立连接的过程通常称为三次握手,断开连接的过程通常称为四次挥手。
在学习 socket API 时,需要注意应用程序和TCP协议层是如何交互的:
TCP和UDP的对比
TCP 和 UDP 都是在网络通信中常用的传输协议,它们之间的主要区别如下:
连接性:TCP 是面向连接的协议,UDP 是无连接的协议。TCP 在通信之前需要先建立连接,而 UDP 则直接发送数据,不需要先建立连接。
可靠性:TCP 是可靠的协议,UDP 是不可靠的协议。TCP 通过三次握手、四次挥手等机制,保证数据的可靠性,数据传输过程中可以进行校验、重传等操作,可以保证数据的完整性。而 UDP 没有这些机制,如果发送的数据丢失或者损坏,就会导致数据的丢失或损坏。
速度:UDP 比 TCP 更快。由于 TCP 需要建立连接和保证可靠性,因此在数据传输过程中需要进行许多额外的操作,导致速度较慢。而 UDP 直接发送数据,没有这些额外的操作,因此速度更快。
TCP 是面向字节流的,UDP 是面向数据报的。面向数据包就是对方发一次,我就接收一次;而面向字节流是对方发多次,我一次就全部接收。
本篇博客基于 TCP 协议编写了单进程版、多进程版、多线程版、线程池版的 echo 服务器、深入剖析地址转换函数以及 TCP 协议的通讯流程等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!❣️