Linux的Socket编程之TCP
目录
1、TCP编程的相关接口
1.1 创建套接字(Server+Client)
1.2 绑定套接字(Server)
1.3 设置监听套接字(Server)
1.4 设置连接套接字(Client)
1.5 创建已连接套接字(Server)
1.6 接收消息 && 发送消息(Server+Client)
1.7 网络数据的转化(Server+Client)
2、CommandServer
2.1 大致思路
2.2 Common.hpp
2.3 Command.hpp
2.4 TcpServer.hpp
2.5 TcpServer.cc
2.6 TcpClient.cc
2.7 示例及完整代码
1、TCP编程的相关接口
1.1 创建套接字(Server+Client)
- 创建套接字本质上是在内核中创建一个用于网络通信的抽象 “文件对象”,并通过文件描述符让用户进程操作它。
#include <sys/types.h>
#include <sys/socket.h>
// 创建套接字
int socket(int domain, int type, int protocol);#include <unistd.h>
// 关闭套接字
int close(int fd);
- socket(AF_INET或PF_INET,SOCK_STREAM,0);。
- AF_INET表示:IPv4 互联网域;AF_INET+SOCK_STREAM表示: TCP;protocol通常是0。AF_INET=PF_INET。
- socket()的返回值,success,返回一个socket的文件描述符(可以读也可以写,且是全双工(有独立的发送缓冲区和接收缓冲区,可以边读边写));error,返回-1。
- close()的返回值,success,返回0;error,返回-1。
1.2 绑定套接字(Server)
- 绑定套接字(IP + 端口)的本质是给通信端点分配一个唯一的网络标识,让消息能在网络中 “准确投递”。
#include <sys/types.h>
#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
-
sockfd,套接字文件描述符。
-
网络通信,addr一般传struct sockaddr_in*并强转为struct sockaddr*,类似于多态。
-
struct sockaddr_in定义在netinet/in.h中。
-
-
sin_family为AF_INET,表示 IPv4 互联网域。
-
sin_port为无符号16位整型的端口号,0-1023为专用端口号,1024-65535为可分配端口号。
-
s_addr为无符号32位整型的IP地址,IP地址一般的表示形式是点分十进制,如:192.168.0.1,但是用字符串需要15个字节,其实4个字节(4个0-255)就行。
-
addrlen为addr的大小。
-
bind()的返回值,success,返回0;error,返回-1。
-
注意:
- 服务端,套接字不建议绑定特定的IP,因为可能一个服务器上有多个IP,需要收到来自多个IP的消息,所以设置为INADDR_ANY(其实就是0),可接收该机器上的任意IP。
- 服务器的端口号port是一个进程标识,需要自行输入,因为服务器的端口号需要固定。
- 客户端,套接字的IP和端口号,OS会自动绑定。OS知道IP,会采用随机端口号,避免端口冲突。如:开 2 个终端运行./udpclient,连同一个服务器,不会因为端口冲突报错:每个客户端的端口都是 OS 随机分配的,互不重复。
- 客户端知道服务器的IP和端口号,自行输入。因为客户端和服务端是同一家公司写的。
- 通常机器上的IP有(通过ip addr查看),本地环回IP 127.0.0.1,和另一个外部IP。本地环回实际上是本地通信,核心价值是,在 “无物理网络” 的情况下,让网络代码与 TCP/IP 协议栈进行完整交互—— 它能覆盖网络代码 90% 以上的核心逻辑测试需求(如套接字操作、协议流程、数据收发),同时不能测试「物理层 / 真实网络异常」,避免了真实网络环境的复杂性(如设备依赖、环境配置),因此成为网络代码开发中最常用的测试手段。
1.3 设置监听套接字(Server)
- 将socket()得到的套接字,设置为专门用于接收客户端的连接请求,不负责实际的数据传输。类似于"前台"。
#include <sys/types.h>
#include <sys/socket.h>int listen(int sockfd, int backlog);
- sockfd,套接字文件描述符。
- 这里先弱化backlog,后面再讲解。
- listen()的返回值,success,返回0;error,返回-1。
1.4 设置连接套接字(Client)
- connect()是客户端用于与服务器建立网络连接。
#include <sys/types.h>
#include <sys/socket.h>int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
- sockfd,套接字文件描述符。
- *addr,输入型参数,所以是const。网络通信,一般传struct sockaddr_in*并强转为struct sockaddr*,带有接受方的IP和端口号。
- addrlen为addr的大小。
- connect()的返回值,success,返回0;error,返回-1。
1.5 创建已连接套接字(Server)
- accept()从监听套接字("前台")的 “已完成连接队列” 中,取出并返回一个新的已连接套接字,专门用于与该客户端进行实际的数据收发。类似于"专属服务员"。
#include <sys/types.h>
#include <sys/socket.h>int accept(int listen_sockfd, struct sockaddr *addr, socklen_t *addrlen);#include <unistd.h>
// 关闭套接字
int close(int fd);
- listen_sockfd,监听套接字文件描述符。
- *addr,输出型参数,网络通信,一般传struct sockaddr_in*并强转为struct sockaddr*,带有发送方的IP和端口号。
- *addrlen,既是输入型参数,表示addr大小;也是输出型参数,表示实际使用的地址结构大小。
- accept()的返回值,success,返回一个新的已连接套接字;error,返回-1。
- 注意:
- 服务器只有监听套接字需要显式bind(),已连接套接字不需要。
- connect()是客户端 “敲门” 的动作,accept()是服务器 “开门迎客” 的动作,二者配合完成 TCP 连接的建立。
1.6 接收消息 && 发送消息(Server+Client)
#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);
fd:文件描述符
buf:存储读取数据的缓冲区
count:请求读取的字节数返回值, >0,读到的字节数, <0,读取失败,==0,对端写端关闭,读到结尾(类似管道)ssize_t write(int fd, const void *buf, size_t count);
fd:文件描述符
buf:包含待写入数据的缓冲区
count:请求写入的字节数返回值, >0,写入的字节数, <0,写入失败
1.7 网络数据的转化(Server+Client)
- 在处理struct sockaddr_in的IP和端口号时,注意对网络序列的转化。
- 注意:
- 网络中的数据都是大端存储(高位字节放低地址),例如:
一个 32 位整数 0x12345678(4 字节),在大端主机中存储为 12 34 56 78(高位字节在前(低地址)),在小端主机中存储为 78 56 34 12(低位字节在前(低地址))。 -
#include <arpa/inet.h>// 端口号 主机 -> 网络(大端),因为是host -> net s表示short 16位 uint16_t htons(uint16_t netshort);// IP 主机 -> 网络(大端),因为是process -> net int inet_pton(int af, const char *src, void *dst);int af,指定地址族(Address Family)。为AF_INET:表示处理 IPv4 地址(32 位)const char *src,输入型参数,为点分十进制的IP地址的字符串。void *dst,输出型参数,4个字节的 IP 地址(大端)。传递&struct sockaddr_in.sin_addr。// 端口号 网络(大端) -> 主机,因为是net -> host s表示short 16位 uint16_t ntohs(uint16_t netshort);// IP 网络(大端) -> 主机,因为是net -> process const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);int af,指定地址族(Address Family)。为AF_INET:表示处理 IPv4 地址(32 位)const void *src,输入型参数,指向二进制形式的 IP 地址(网络字节序,大端)。传递&struct sockaddr_in.sin_addr。char *dst,输出型参数,存储转换后的点分十进制的字符串形式的 IP 地址。size为dst的大小。
- ntop(),pton(),是线程安全的。
- 上面的函数,主机 -> 网络(大端),保证struct sockaddr_in里面的IP和端口号是大端存储;网络(大端) -> 主机,要取出来会自行判断大小端并进行转化。
- 字节序(大端 / 小端)解决的是 “多字节数据” 在内存中如何排列的问题。
char 类型在 C 语言中占 1 个字节(8 位),它本身没有 “高低位字节” 的概念 —— 单个字节就是最小的存储单位,不存在 “排列顺序” 问题(多字节读取的问题)。
2、CommandServer
2.1 大致思路
- 实现一个CommandServer,TcpClient给TcpServer发什么命令,TcpServer就执行对应的命令,并给TcpClient回命令的执行结果。那么客户端类似于一个"Xshell"。
2.2 Common.hpp
- Common.hpp,为网络编程经常使用的代码。
- 网络编程需要的一些头文件及进行类型转换的宏。
- 退出码使用枚举类型,更规范一点。
- 服务器一般禁止拷贝和赋值,一般是单例。这里使用NoCopy作为父类,服务器作为子类,继承NoCopy,来禁止拷贝和赋值。原理:在 C++ 中,子类的调用自己的拷贝构造函数时,会自动调用基类的拷贝构造函数完成基类部分的初始化;同理,子类的拷贝赋值也会自动调用基类的拷贝赋值。但是NoCopy的拷贝构造和拷贝赋值已被删除(不可访问),子类也无法完成拷贝 / 赋值的完整逻辑,会触发编译错误。因此,子类对象同样不能被拷贝或赋值。
- 网络数据的转化也是网络编程经常使用的部分。
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#define CONST_CONV(addr) ((const struct sockaddr*)(&addr))
#define CONV(addr) ((struct sockaddr*)(&addr))enum ExitCode
{OK = 0,SOCKET_ERROR,BIND_ERROR,LISTEN_ERROR,USAGE_ERROR,CONNECT_ERROR
};class NoCopy
{
public:NoCopy(){}NoCopy(const NoCopy& ) = delete;NoCopy& operator=(const NoCopy& ) = delete;~NoCopy(){}
};class InetAddr
{
public:InetAddr(uint16_t port) :_port(port){// 主机 -> 网络memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;_addr.sin_addr.s_addr = INADDR_ANY;_addr.sin_port = htons(_port);}InetAddr(struct sockaddr_in &addr): _addr(addr){// 网络 -> 主机char buf[32];inet_ntop(AF_INET, &_addr.sin_addr, buf, sizeof(buf) - 1);_ip = buf;_port = ntohs(_addr.sin_port);}InetAddr(const std::string &ip, uint16_t port): _ip(ip), _port(port){// 主机 -> 网络memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);_addr.sin_port = htons(_port);}std::string Ip() const{return _ip;}uint16_t Port() const{return _port;}const struct sockaddr_in& Addr() const{return _addr;}socklen_t AddrLen() const {return sizeof(_addr);}std::string StringAddr() const{return _ip + ":" + std::to_string(_port);}bool operator==(const InetAddr &addr) const{return _ip == addr._ip && _port == addr._port; // 我们任务ip和port相同,才相等;允许一个ip的多个端口访问。}private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};
2.3 Command.hpp
#include <stdio.h>FILE *popen(const char *command, const char *type);int pclose(FILE *stream);
- popen(),创建一个管道,fork 一个子进程执行指定的系统命令,并将并将子进程的标准输出(或标准输入)与管道关联,返回管道文件的 FILE* 指针,供父进程读写。
- type 为 "r" 时,子进程的标准输出(stdout) 会被重定向到管道的写端;
- type 为 "w" 时,子进程的标准输入(stdin) 会被重定向到管道的读端。
- pclose(),关闭 popen() 返回的文件流。还会等待子进程执行结束并回收其资源(避免僵尸进程),同时返回子进程的退出状态。
#pragma once#include <cstdio>
#include "Common.hpp"
#include "Log.hpp"using namespace LogModule;class Command
{
public:std::string Execute(const std::string& cmd,const InetAddr& client){FILE* file_ptr = popen(cmd.c_str(),"r"); // 将子进程的标准输出重定向到管道的w端if(file_ptr == nullptr){LOG(LogLevel::WARNING) << client.StringAddr() << "# " << cmd << " 该命令无效!";return "你要执行的命令无效: "+cmd;}std::string result;char line[256];// char *fgets(char *str, int n, FILE *stream);// 第二个参数 n 的含义是:最多读取 n-1 个字符到 str 中,自动在末尾加 '\0'。// snprintf也是while(fgets(line,sizeof(line),file_ptr)){result += line; }pclose(file_ptr);LOG(LogLevel::DEBUG) << client.StringAddr() << "# " << result;return "execute done, result is: \n" + result;}
};
2.4 TcpServer.hpp
- 单进程的服务器,只能处理一个客户的服务(因为Service是死循环),一般不存在。
- 多进程的服务器,但是父进程要等待子进程,那么就会阻塞;如果使用非阻塞等待,在accept()处会阻塞,如果子进程这个时候退出,就回收不了。
- 解决方案1:子进程退出会对父进程发送信号,但是父进程默行为是不处理,可以对该信号进行自定义捕捉,signal(SIGCHLD, handler); ,父进程对该信号的处理是回收子进程,相当于子进程会被自动回收。
- 解决方案2:父->子->孙,孙子进程执行任务,父进程立即将子进程回收,此时孙子进程为孤儿进程,被1号进程"领养",会被自动回收。
- 注意:如果孙子进程已经变成僵尸进程,这个时候子进程没有回收,并且子进程退出了,这个时候,僵尸孙子进程(已终止)不会变成孤儿进程(仍在运行),但会被内核 “过继” 给 init 进程(或 systemd,PID=1),最终由1号进程自动回收。
- 画外音:
- 1. 对于子进程,没"父亲"(被1号进程领养),就是安全的,有父亲,但不回收,就是不安全的。
- 2. 难道1号进程,对子进程的退出时的信号处理就是SIG_ING,自动回收?可能是。
- 多线程的服务器,也有需要join线程的问题,但是可以detach(),自动回收。
- 注意:进程是copy了文件描述符表的,_listen_sockfd和sockfd文件的引用计数是增加了的,对于子进程需要关闭_listen_sockfd,对于父进程要关闭sockfd。但是对于线程,文件描述符表是共享的,不能关闭_listen_sockfd。
- 对于长服务,一般用于多进程(求稳定、隔离)或多线程(求共享、高效) ,核心是保障长期运行不中断;但是网络中大部分是短服务(如注册,登录),一般用线程池, 核心是通过 “线程复用” 降低资源创建 / 销毁开销,提升吞吐和响应速度。
#pragma once#include <vector>
#include <queue>
#include <thread>
#include <memory>
#include <atomic>
#include "Log.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"namespace ThreadPoolModule
{using namespace MutexModule;using namespace CondModule;using namespace LogModule;const int gnum = 5; // 线程个数template <typename T>class ThreadPool{private:ThreadPool(int num = gnum): _num(num), _sleep_num(0), _running(true){for (int i = 0; i < _num; ++i){// 隐式使用了this,在成员函数内部默认是this->HandlerTask()_threads.emplace_back([this](){ HandlerTask(); });}}ThreadPool(const ThreadPool &) = delete;ThreadPool &operator=(const ThreadPool &) = delete;public:static ThreadPool *GetInstance(){if (_inc == nullptr){LockGuard lockguard(_inc_mutex);if (_inc == nullptr){LOG(LogLevel::DEBUG) << "首次使用单例, 创建之....";// _inc = std::make_unique<ThreadPool>();// std::make_unique 是一个外部函数,无法访问 ThreadPool 类的私有构造函数_inc.reset(new ThreadPool());}}LOG(LogLevel::DEBUG) << "获取单例";return _inc.get(); // 返回原始指针,不转移所有权}void HandlerTask(){while (true){T task;{LockGuard lockguard(_tasks_mutex);while (_tasks.empty() && _running){++_sleep_num;_tasks_cond.Wait(_tasks_mutex);--_sleep_num;}if (_tasks.empty() && !_running)break;task = _tasks.front();_tasks.pop();}task();}}void Enqueue(const T &task){if (_running){LockGuard lockguard(_tasks_mutex);if (_running){_tasks.push(task);if (_sleep_num == _num){LOG(LogLevel::INFO) << "唤醒一个休眠线程";_tasks_cond.Signal();}}}}void Stop(){if (!_running)return;_running = false;if (_sleep_num){LOG(LogLevel::INFO) << "唤醒所有休眠线程";_tasks_cond.Broadcast();}}void Join(){LOG(LogLevel::INFO) << "join所有线程";for (auto &thread : _threads){if (thread.joinable())thread.join();}}private:std::vector<std::thread> _threads;int _num; // 线程个数int _sleep_num; // 线程阻塞个数std::queue<T> _tasks;Mutex _tasks_mutex;Cond _tasks_cond;static std::unique_ptr<ThreadPool> _inc;std::atomic<bool> _running; // 线程池是否启动。static Mutex _inc_mutex;};template <typename T>std::unique_ptr<ThreadPool<T>> ThreadPool<T>::_inc = nullptr;template <typename T>Mutex ThreadPool<T>::_inc_mutex;
}
2.5 TcpServer.cc
#include "TcpServer.hpp"
#include "Command.hpp"
#include <memory>// ./tcpserver server_port
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " server_port" << std::endl;exit(USAGE_ERROR);}Enable_Console_Log_Strategy();uint16_t server_port = std::stoi(argv[1]);Command cmd;std::unique_ptr<TcpServer> tcp_server = std::make_unique<TcpServer>(server_port,bind(&Command::Execute,&cmd,std::placeholders::_1,std::placeholders::_2));tcp_server->Init();tcp_server->Start();return 0;
}
2.6 TcpClient.cc
#include "Common.hpp"
#include "Log.hpp"using namespace LogModule;// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;exit(USAGE_ERROR);}Enable_Console_Log_Strategy();std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);InetAddr server(server_ip, server_port);// 1. 创建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){LOG(LogLevel::FATAL) << "socket error!";exit(SOCKET_ERROR);}// 2. OS自动bind客户端的套接字// 3. 设置连接套接字int n = connect(sockfd, CONST_CONV(server.Addr()), server.AddrLen());if (n < 0){LOG(LogLevel::FATAL) << "connect error!";exit(CONNECT_ERROR);}while(true){std::cout << "please input @ ";std::string line;std::getline(std::cin,line);write(sockfd,line.c_str(),line.size());char buf[256];ssize_t s = read(sockfd,buf,sizeof(buf)-1);if(s > 0){buf[s] = 0;std::cout << "server echo# " << buf << std::endl;}}close(sockfd);return 0;
}
2.7 示例及完整代码
- 示例:
- 注意:当客户端没有退出,但是服务端先退出了,再重新启动,服务端会bind()失败。这个问题后面再讲解。
- 完整代码:CommandServer。