文章目录
- 1.TCP建立
- 2.服务端
-
- [2.1 启动函数main](#2.1 启动函数main)
- [2.2 服务端创建与处理](#2.2 服务端创建与处理)
-
- [2.2.1 守护进程](#2.2.1 守护进程)
- [2.2.2 接收客户端信息](#2.2.2 接收客户端信息)
- [2.2.3 处理客户端信息](#2.2.3 处理客户端信息)
- 3.客户端
- 希望读者们多多三连支持
- 小编会继续更新
- 你们的鼓励就是我前进的动力!
1.TCP建立
由于
TCP「有连接、可靠、面向字节流」的特性,分服务端和客户端两步实现,核心是「套接字创建 + 连接建立 + 数据收发」
- 服务端需通过
listen()监听端口、accept()阻塞等待客户端连接 - 客户端需通过
connect()主动发起连接,完成三次握手后才能进行数据收发,这是TCP与UDP实现的核心差异
由于代码量较多,具体可查看Gitee仓库:https://gitee.com/zhang-zhanhua-000/linux/tree/master/tcp
2.服务端
2.1 启动函数main
cpp
#include "TcpServer.hpp"
#include <memory>
Log logger;
void Usage(const char *proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
logger.Enable(Classfile);
std::unique_ptr<TcpServer> svr(new TcpServer(port));
svr->InitServer();
svr->Start();
return 0;
}
和 udp 相同,创建唯一的服务进程,但是这里需要设置将日志打印到文件中而不是屏幕,主要是因为我们要使用守护进程,后面会进行说明
2.2 服务端创建与处理
cpp
#pragma once
#include <iostream>
#include <string>
#include <stdlib.h>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <signal.h>
#include "log.hpp"
#include <pthread.h>
#include "ThreadPool.hpp"
#include "Task.hpp"
#include "Daemon.hpp"
const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";
const int backlog = 10;
extern Log logger;
enum
{
UsageError = 1,
SocketError,
BindError,
ListenError
};
class TcpServer;
class ThreadData
{
public:
ThreadData(int fd, const std::string& ip, const uint16_t& port, TcpServer* t)
: sockfd(fd)
, clientip(ip)
, clientport(port)
, tsvr(t)
{}
public:
int sockfd;
std::string clientip;
uint16_t clientport;
TcpServer* tsvr;
};
class TcpServer
{
public:
TcpServer(const uint16_t &port, const std::string &ip = defaultip)
: listensock_(defaultfd)
, port_(port)
, ip_(ip)
{}
void InitServer()
{
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
logger(Fatal, "create socket, error: %d, errstring: %s", errno, strerror(errno));
exit(SocketError);
}
logger(Info, "create socket success, listensock_: %d", listensock_);
int opt = 1;
setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR, (const void *)&opt, sizeof(opt));
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(listensock_, (struct sockaddr*)&local, sizeof(local)) < 0)
{
logger(Fatal, "bind error, error: %d, errstring: %s", errno, strerror(errno));
exit(BindError);
}
logger(Info, "bind socket success, listensock_: %d", listensock_);
if(listen(listensock_, backlog) < 0)
{
logger(Fatal, "listen error, error: %d, errstring: %s", errno, strerror(errno));
exit(ListenError);
}
logger(Info, "listen socket success, listensock_: %d", listensock_);
}
static void* Routine(void* args)
{
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
delete td;
return nullptr;
}
void Start()
{
Daemon();
//signal(SIGPIPE, SIG_IGN);
ThreadPool<Task>::GetThreadPool()->Start();
logger(Info, "TcpServer is running...");
for(;;)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
logger(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
logger(Info, "get a new link..., sockfd: %d\n", sockfd);
// v1 --- 单进程版
// Service(sockfd, clientip, clientport);
// close(sockfd);
// v2 --- 多进程版
// pid_t id = fork();
// if(id == 0)
// {
// close(listensock_);
// if(fork() > 0) exit(0);// 孙子进程
// Service(sockfd, clientip, clientport);
// close(sockfd);
// exit(0);
// }
// close(sockfd);
// pid_t rid = waitpid(id, nullptr, 0);
// (void)rid;
// v3 --- 多线程版
// ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
// pthread_t tid;
// pthread_create(&tid, nullptr, Routine, td);
// v4 --- 线程池版
Task t(sockfd, clientip, clientport);
ThreadPool<Task>::GetThreadPool()->Push(t);
}
}
void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
{
char buffer[4096];
while(true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0)
{
logger(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
break;
}
}
}
~TcpServer()
{}
private:
int listensock_;
uint16_t port_;
std::string ip_;
};
初始化套接字不多说了,和 udp 基本差不多,sock、bind,然后就是多了个 listen,由于 tcp 需要保证连接的可靠性,务端必须先 "处于监听状态",才能接收客户端的 connect() 连接请求,开启端口监听
backlog 参数表示 tcp 连接队列的最大长度,不会太大,一般设置为 5,在 tcp 协议会详细讲解
2.2.1 守护进程
cpp
┌──────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────┐ │
│ │ 会话 Session 1 │ │ ← 会话:进程管理单元
│ │ (会话首进程 = bash 1,SID = bash 1的PID) │ │ 绑定一个PTY,管理一组进程
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ bash 1 │ │ 进程A │ │ 进程B │ │ │ ← bash:命令解释器(会话首进程)
│ │ │ (大脑) │ │ (./app) │ │ (top) │ │ │ 解析命令,生成子进程
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 会话 Session 2 │ │ ← 每个SSH渠道对应一个独立会话
│ │ (会话首进程 = bash 2,SID = bash 2的PID) │ │ 会话之间完全隔离
│ │ ┌───────────┐ ┌───────────┐ │ │
│ │ │ bash 2 │ │ 进程C │ │ │
│ │ └───────────┘ └───────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
要了解守护进程,我们要知道什么是会话,你打开一个 Xshell 窗口,这叫终端,在终端里创建多个 ssh 渠道,每个渠道里可以运行很多进程或线程,这每一个 ssh 渠道就是会话,会话和会话之间是独立开来的
我们知道,任务分为前台任务和后台任务,发现前台任务运行时可以执行命令行,但是后台任务不行,这是因为前台任务只能存在一个,而且要一直存在,要么是 bash(命令行交互进程,默认前台),要么是自己运行的前台任务,而后台任务可以有很多个
创建后台进程

这个 [1] 2420845 表示序号 1 的后台任务进程 PID 为 2420845
查看后台进程

jobs 命令查看后台进程
后台转前台&&暂停任务

fg+序列号 表示把后台任务转为前台任务,CTRL+Z 表示暂停任务
暂停的前台切换到后台继续运行

bg+序列号 执行后,作业会在后台恢复运行,终端可继续输入新命令

| 列名 | 含义(对应当前输出) |
|---|---|
| PPID | 父进程ID:第一行1表示该进程的父进程是系统初始化进程(init/systemd);第二行2419975是当前终端进程的ID |
| PID | 进程ID:第一行2428845是./TcpServer的进程号;第二行2428864是grep的进程号 |
| PGID | 进程组ID:第一行2428845(自身为进程组组长);第二行2428863是grep所属进程组的ID |
| SID | 会话ID:第一行2428845(自身为会话组长);第二行2419975是当前终端会话的ID |
| TTY | 终端设备:第一行?表示该进程不关联终端(通常是后台守护进程类程序);第二行pts/6是当前伪终端 |
| TPGID | 终端进程组ID:第一行-1(无关联终端,所以无值);第二行2428863是当前终端前台进程组的ID |
| STAT | 进程状态:第一行Ssl表示进程处于休眠(S)、是会话组长(s)、以线程方式运行(l);第二行R+表示进程处于运行状态(R)且位于前台(+) |
| UID | 进程所属用户ID:1000是当前普通用户 |
| TIME | 进程占用CPU时间:0:00表示尚未占用有效CPU时间 |
| COMMAND | 进程启动命令:第一行是运行./TcpServer并监听8888端口;第二行是grep命令匹配"8888" |
了解完以上知识点,我们就很容易理解守护进程了,实现守护进程的前提是他不能是进程组组长,setsid() 对进程组组长无效,setsid() 的作用是让调用进程脱离原会话、原进程组,创建新会话并成为新会话的首进程,同时成为新进程组的组长,且没有控制终端
cpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string nullfile = "/dev/null";
void Daemon(const std::string& cwd = "")
{
signal(SIGCLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP,SIG_IGN);
if(fork() > 0)
{
exit(0);
}
setsid();
if(!cwd.empty())
{
chdir(cwd.c_str());
}
int fd = open(nullfile.c_str(), O_RDWR);
if(fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
这是一个简易的守护进程创建函数,首先通过忽略SIGCLD(子进程终止信号)、SIGPIPE(管道破裂信号)、SIGSTOP(进程停止信号)避免守护进程被这些信号意外中断,接着调用fork()创建子进程并让父进程直接退出,确保子进程不是进程组组长(从而能成功调用后续的setsid()),随后setsid()创建新会话,让子进程脱离原控制终端、原进程组和原会话,成为新会话首进程和新进程组组长,实现真正后台运行
如果传入了非空的工作目录参数,会通过chdir()切换到该指定工作目录,目的是为了避免守护进程依赖原工作目录,当进程执行过后,执行文件就在内存中生成了,不必过度担心源文件是否被删除,除非是动态的
最后打开/dev/null(空设备文件,相当于一个垃圾桶,往里面扔什么都无效),并通过dup2()将标准输入(0)、标准输出(1)、标准错误(2)重定向到该空设备,关闭原文件描述符,避免守护进程后续的 I/O 操作依赖原控制终端,完成守护进程的核心创建流程

其实系统中也有提供相应的实现守护进程的函数,但是为了准确实现功能,项目中一般自行实现 daemon 函数
在命令行可以用 nohup 命令 & 启动(如nohup ./TcpServer 8888 &)守护进程
2.2.2 接收客户端信息

accept 用于接收客户端连接的,从已完成三次握手的连接队列中取出一个连接,创建新的套接字与客户端通信
- 监听套接字 vs 已连接套接字 :
sockfd是监听套接字(持续存在,用于接收新连接)accept返回的是新套接字(仅用于与当前客户端通信,通信结束后需关闭)
就好比监听套接字是酒店前台,而接收套接字是各个房间
- 阻塞/非阻塞模式 :
- 默认是阻塞模式:若连接队列为空,
accept会阻塞直到有新连接 - 若将监听套接字设为非阻塞(
fcntl设置O_NONBLOCK),无连接时会立即返回-1并置errno=EAGAIN
- 默认是阻塞模式:若连接队列为空,
2.2.3 处理客户端信息
cpp
void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
{
char buffer[4096];
while(true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0)
{
logger(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
break;
}
}
}
我们将设置一个 Service 函数用于处理客户端发来的信息,同时方便清晰展示功能实现,以下是四种代码调用方式:
v1 --- 单进程版
cpp
Service(sockfd, clientip, clientport);
close(sockfd);
accept 接收到客户端连接后,直接在主进程中调用 Service 处理通信,通信结束后关闭连接套接字 ,再循环等待下一个连接,以最简单的方式实现,但是这无法适用高并发场景,同一时间只能处理一个客户端的请求,其他客户端连接会被阻塞在 listen 的连接队列中,直到当前客户端断开连接,仅适用于测试功能场景
v2 --- 多进程版
cpp
pid_t id = fork();
if(id == 0)
{
close(listensock_);
if(fork() > 0) exit(0);// 孙子进程
Service(sockfd, clientip, clientport);
close(sockfd);
exit(0);
}
close(sockfd);
pid_t rid = waitpid(id, nullptr, 0);
(void)rid;
accept 成功后,主进程 fork 子进程,由子进程专门处理客户端通信(子进程中关闭监听套接字 ),主进程关闭连接套接字 并继续 accept 新连接;同时通过 "子进程再 fork 产生孙子进程" 的方式,让孙子进程成为孤儿进程被系统收养,避免主进程产生僵尸进程
注意父进程一定要关闭已经打开的文件描述符(此时孙子进程正常执行已经用不到了),不然文件描述符越堆越多,只会不够用
此时多进程真正实现了并发服务,但是进程创建的内存开销(进程地址空间、页表等)远大于线程,高并发下大量创建子进程会迅速耗尽系统内存、文件描述符等资源,导致服务器卡死,要是复杂环境下 fork 逻辑不完善还有僵尸进程的风险
v3 --- 多线程版
cpp
class TcpServer;
class ThreadData
{
public:
ThreadData(int fd, const std::string& ip, const uint16_t& port, TcpServer* t)
: sockfd(fd)
, clientip(ip)
, clientport(port)
, tsvr(t)
{}
public:
int sockfd;
std::string clientip;
uint16_t clientport;
TcpServer* tsvr;
};
...
static void* Routine(void* args)
{
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
delete td;
return nullptr;
}
void Start()
{
...
for(;;)
{
...
ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
}
}
accept 成功后,创建一个新线程,将连接套接字、客户端 IP / 端口 等信息封装到 ThreadData 中传递给线程入口函数 Routine,由线程专门处理客户端通信;同时调用 pthread_detach 分离线程,避免主线程需要调用 pthread_join 回收线程资源(主要是为了提高并行效率,不要等主线程来释放,让通信完的线程自行释放,能让主线程有更多时间去创建新线程)
线程是轻量级执行流,共享进程的地址空间,创建线程的内存开销远小于进程,高并发下可以创建更多线程处理连接,资源利用率更高
但是多个线程共享进程资源,若对共享数据的访问未加锁保护,会出现数据竞争、数据混乱等问题,甚至导致程序崩溃
v4 --- 线程池版
Task.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"
extern Log logger;
class Task
{
public:
Task(int sockfd, const std::string& clientip, const uint16_t& clientport)
: sockfd_(sockfd)
, clientip_(clientip)
, clientport_(clientport)
{}
Task()
{}
void run()
{
char buffer[4096];
ssize_t n = read(sockfd_, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
echo_string += buffer;
n = write(sockfd_, echo_string.c_str(), echo_string.size());
if(n < 0)
{
logger(Warning, "write error, sockfd: %d, client ip: %s, client port: %d", sockfd_, clientip_.c_str(), clientport_);
}
}
else if(n == 0)
{
logger(Info, "%s:%d quit, server close sockfd: %d", clientip_.c_str(), clientport_, sockfd_);
}
else
{
logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd_, clientip_.c_str(), clientport_);
}
close(sockfd_);
}
~Task()
{}
private:
int sockfd_;
uint16_t clientport_;
std::string clientip_;
};
ThreadPool.hpp
这里不作具体展示,详细可查看:【Linux操作系统】简学深悟启示录:线程同步与互斥
cpp
Task t(sockfd, clientip, clientport);
ThreadPool<Task>::GetThreadPool()->Push(t);
提前创建一个固定数量的线程组成生产消费者模型的线程池,且该线程池为单例模式,accept 成功后,将客户端连接封装为 Task 任务,推入线程池的任务队列中;线程池中的空闲线程会从任务队列中取出任务并执行通信逻辑,任务完成后线程不会退出,而是继续等待下一个任务
这个版本已经是一个很好的版本了,不仅能够高并发访问,还能解决资源共享时数据竞争的问题,线程复用避免了频繁的线程上下文切换,不会出现因大量线程切换导致的性能骤降
3.客户端
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string>
#include <stdlib.h>
#include <cstring>
#include <signal.h>
#include <unistd.h>
void Usage(const std::string &proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
signal(SIGPIPE, SIG_IGN);
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(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 = htonl(stoi(serverip));
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
std::cerr << "sockfd error..." << std::endl;
return 1;
}
int cnt = 5;
bool isreconect = false;
do
{
int n1 = connect(sockfd, (sockaddr *)&server, sizeof(server));
if (n1 < 0)
{
std::cerr << "connect error..., reconect: " << cnt << std::endl;
cnt--;
isreconect = true;
sleep(1);
}
else
{
break;
}
} while (cnt && isreconect);
if (cnt == 0)
{
std::cerr << "user offline..." << std::endl;
close(sockfd);
return 1;
}
while (true)
{
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
if (message.empty())
{
continue;
}
if (message == "quit" || message == "exit")
{
std::cout << "client exit..." << std::endl;
break;
}
int n2 = write(sockfd, message.c_str(), message.size());
if (n2 < 0)
{
std::cerr << "write error..." << std::endl;
continue;
}
char inbuffer[4096];
ssize_t n3 = read(sockfd, inbuffer, sizeof(inbuffer));
if (n3 > 0)
{
inbuffer[n3] = 0;
std::cout << inbuffer << std::endl;
}
else if (n3 == 0)
{
std::cerr << "server disconnect..." << std::endl;
break;
}
else
{
std::cerr << "read error..." << std::endl;
break;
}
}
close(sockfd);
return 0;
}
客户端只建立一次连接,完成多条消息的交互,并加入了重连机制,直到用户主动退出再关闭套接字
这里刻意忽略了 SIGPIPE,服务端也做了同样的处理,但作用完全不同
- 服务端: 由于会有多个客户端不断连接断开服务端,当一个客户端断开时,只有服务端连接着管道,此时服务端的进程会被默认杀死,所以需要忽略
SIGPIPE - 客户端: 忽略后
write()会返回-1并设置errno=EPIPE,程序可以捕获该错误并自行处理(而非直接崩溃),让客户显式看到具体出错
希望读者们多多三连支持
小编会继续更新
你们的鼓励就是我前进的动力!
