目录
[1. 对一个本地信息进行返回](#1. 对一个本地信息进行返回)
[1.1 服务端](#1.1 服务端)
[1.1.1 创建 socket 文件描述符](#1.1.1 创建 socket 文件描述符)
[1.1.2 填充本地网络信息并bind](#1.1.2 填充本地网络信息并bind)
[1.1.3 listen 开始监听socket (TCP, 服务器)](#1.1.3 listen 开始监听socket (TCP, 服务器))
[1.1.4 accept 接收请求 (TCP, 服务器)](#1.1.4 accept 接收请求 (TCP, 服务器))
[1.1.5 处理消息。](#1.1.5 处理消息。)
[1.2 客户端:](#1.2 客户端:)
[1.2.1 connect 建立连接 (TCP, 客户端)](#1.2.1 connect 建立连接 (TCP, 客户端))
1. 对一个本地信息进行返回
1.1 服务端
1.1.1 创建 socket 文件描述符
创建 socket 文件描述符,指向套接字对象。成功时返回一个文件描述符,失败时返回 -1,并设置对应的错误码。
cpp
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain 用于指定通信类型:AF_UNIX 表示域间通信,AF_INET 表示网络通信,它是一个宏,值为 2。
type 用于指定套接字类型,常用的有两种:SOCK_STREAM 是流式套接字,通常用于 TCP;SOCK_DGRAM 是面向数据报的套接字,用于无连接的 UDP。此外还有 SOCK_RAW 底层原始套接字。第三个参数用于指定使用 TCP 还是 UDP 协议,在网络通信中,前两个参数固定后,第三个参数缺省为 0 即可。
1.1.2 填充本地网络信息并bind
-
成功:返回
0 -
失败:返回
-1,并设置全局变量errno
cpp
struct sockaddr_in local;
INADDR_ANY;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET; // 使用ipv4地址簇
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // 全0的ip htonl 加不加都行
int n = bind(_listensock, CONV(&local), sizeof(local));
1.1.3 listen 开始监听socket (TCP, 服务器)
客户端和服务器双方要进行数据通信,必须先建立连接。通常由客户端主动发起连接,而服务器则需要一直等待连接的到来,因此要将服务器设置为监听状态。
cpp
#include <sys/types.h>
#include <sys/socket.h>
int listen(int socket, int backlog);
将套接字设置为监听状态,即创建出的服务器套接字。第二个参数我们之后讲原理时再详细说明,现在先设为 5。成功返回 0,失败返回 -1,并设置错误码。
1.1.4 accept 接收请求 (TCP, 服务器)
cpp
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
后两个参数就是输入输出型参数获取客户端的 ip 地址端口号等信息,与 udp 中 recvfrom 中的两个参数相同。accept 默认会阻塞 ,但取决于监听套接字是"阻塞模式"还是"非阻塞模式"。成功返回新的套接字文件描述符 ,原来的 Listenfd(socket) **继续用于监听,**继续 accept 其他客户端。失败返回 -1,并设置错误码。也就是使用新的 fd 和 client 进行数据通信。
1.1.5 处理消息。
TCP 是面向字节流与管道和文件特性相同,可以read,write来处理,read返回 0 表示读到文件结尾 (对端关闭连接,如果只是对端没有关闭会阻塞,不会有返回值)。
cpp
void Service(int sockfd)
{
//tcp 面向字节流与管道和文件特性相同,可以read,write来处理
char buffer[1024];
while(true)
{
//read 大于0,文件结尾,-1出错
size_t n = read(sockfd,buffer,sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
}
string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd,echo_string.c_str(),echo_string.size());//读到数据并返回
}
else if(n == 0)//read读到0表示读到文件结尾(对端关闭连接,没有关闭会阻塞)
{
lg.LogMessage(Info,"client quit...\n");
break;
}
else
{
lg.LogMessage(Error,"accept socket error,errno code:%d,error string%s\n",
errno,strerror(errno));
break;
}
}
}</code></pre>
netstat -nltp n:num,显示成数字形式,l:listen,监听状态;t:tcp;p:process; 显示出处于监听状态的 tcp 进程。
1.2 客户端:
inet_pton 把字符串形式的 IP 地址转换成二进制网络字节序地址,IPv4就会转化为4字节的网络字节序列。af 地址簇,src 原字符串 IP 地址,dst 输出缓冲区。IPv4 传struct in_addr*,IPv6 传 struct in6_addr*。
cpp
#include <arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);
//示例:
cpp
inet_pton(AF_INET,serverip.c_str(),&server.sin_addr);
1.2.1 connect 建立连接 (TCP, 客户端)
cpp
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
连接成功,返回0,会自动 bind IP地址,端口号。连接失败,返回-1。然后我们通过 write 写入,如果服务端关了这里会产生一个sigpipe的信号,但对端关闭这里也会写入成功,因为这里是写到客户端自己进程的sockfd中了。
cpp
#define CONV(addr_ptr) ((struct sockaddr*)addr_ptr)
bool visitServer(std::string& serverip,uint16_t& serverport)
{
//1.创建socket
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
int ret = true;
//client 不需要显示绑定;发起连接的时候,client会被 OS自动地进行本地绑定
//2.connet 连接server服务端
struct sockaddr_in server;//服务端信息
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET,serverip.c_str(),&server.sin_addr);//转换为网络字节序列
int n = connect(sockfd,CONV(server),sizeof(server));//成功自动bind
if(n != 0)
{
std::cerr << " connect error " << std::endl;
return false;
}
while(true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
getline(std::cin,inbuffer);
if(inbuffer == "quit")
break;
ssize_t n = write(sockfd,inbuffer.c_str(),inbuffer.size());
//如果服务端关了这里会产生一个sigpipe的信号
//对端关闭这里也会写入成功
if(n > 0)
{
char buffer[1024];
ssize_t m = read(sockfd, buffer,sizeof(buffer)-1);
if(m > 0)
{
buffer[m] = 0;
std::cerr << "get a echo message->" << buffer << std::endl;
}
else if(m == 0)
{
std::cout << "服务器关闭" << std::endl;
}
else
{
return false;
}
}
else
{
return false;
}
}
END:
close(sockfd);
return true;
}
2.通过孙子进程实现处理消息
子进程会继承父进程的 PCB 和文件描述符表,父子文件描述符表里的内容相同,指向相同的文件,我们可以让父线程获取链接成功,accept 返回 sockfd 后,创建子进程对客户端进行处理,并且父子进程关闭自己不需要的fd,否则会造成文件描述符泄露。
父进程创建子进程,子进程执行完,需要等待处理,变为僵尸进程 ,我们只能选择阻塞等待,因为如果设置为非阻塞, 可能有子进程处理完等待回收,但父进程循环 accept 等不到新任务,会 continue继续循环,所以非阻塞等待没有意义。但阻塞等待还是一次只能等待处理完一个客户,我们我可以让子进程创建孙子进程,然后让子进程退出,只留下孙子进程 ,这样父进程可以直接 wait 成功子进程,解决了子进程变成僵尸进程的问题,孙子进程处理任务,退出时因为他爹子进程没了,成了孤儿进程,由系统回收。这里 sockfd 为 accept 成功的返回值。
cpp
pid_t id = fork();
if (id < 0)
{
close(sockfd);
continue;
}
else if (id == 0) // 子进程
{
close(_listensock);
if (fork() > 0)
exit(0); // 子进程退出,waitpid立马返回,不影响父进程 accept
// 孙子进程,正常处理,退出时因为他爹子进程没了,成立孤儿进程,系统回收
Service(sockfd);
close(sockfd);
exit(0);
}
else // 父进程
{
close(sockfd);
pid_t rid = waitpid(id, nullptr, 0); // 阻塞等待,不关心退出状态
// 如果 设置为非阻塞, 可能有子进程处理完等待回收,
// 但父进程循环 accept 等不到新任务,会 continue继续循环,所以循环等待没有意义
if (rid == id)
{
// 正确等待到了子进程
}
}
3.进程信号版处理客户消息
在Linux中对 SIG_CHLD 进行忽略,子进程退出的时候,会自动释放自己的资源。
cpp
signal(SIGCHLD,SIG_IGN);
pid_t id = fork();
if (id < 0)
{
close(sockfd);
continue;
}
else if (id == 0) // 子进程
{
close(_listensock);
Service(sockfd);
close(sockfd);
exit(0);
}
else // 父进程
{
close(sockfd);
//不等待,不关心子进程退出状态
}
也可以使用进程池 --- 问题是先创建的子进程, 然后父进程才与客户端建立链接,子进程看不到新获取的文件fd,可以网上找代码。
4.多线程处理客户消息
因为不知道是新线程先执行还是主线程先执行,当主线程先执行时如果直接传入sockfd到新线程,如果新线程不及时执行,主线程中的 sockfd 可能导致被更新,所以需要创建一个 ThreadData 保存下来。意思就是不能这样写:新线程想要读取 sockfd 时可能已经晚了,确定的是地址,不是地址里面保存的值。
cpp
int sockfd;
while (1) {
sockfd = accept(listenfd, ...);
pthread_create(&tid, NULL, worker, &sockfd);
}
正确做法:
cpp
ThreadData *td = new ThreadData(sockfd, this, peer);
// 因为不知道是新线程先执行还是主线程先执行,当主线程先执行直接传入sockfd可能导致被更新
pthread_t tid;
pthread_create(&tid, nullptr, HandlerRequest, td);
// 主线程和新线程,不需要所谓的文件描述符表,因为共用
// pthread_join();//新线程需要被join,但此时主线程就被阻塞了,所以将线程分离
主线程和新线程,不需要所谓的文件描述符表,因为共用,不增加引用计数。不能使用 pthread_join() ,新线程需要被join,但此时主线程就会被阻塞,所以需要将线程进行分离。
同一个对象,this 指针的值是一样的。多线程共享同一个进程地址空间,对象只有一个,this 就是那个对象的地址。我们传入的方法不能直接是类内,因为 pthread_create 传入类型要求为 void *(*)(void *) ,而类内非静态成员函数类型第一个参数是this指针,所以我们把方法改为了静态,但内部还调用了一个类内 Server 方法,所以我们 ThreadData 也传入了this指针,处理不同的 sockfd 任务。
cpp
class TcpServer;
class ThreadData
{
public:
ThreadData(int sock, TcpServer *ptr, struct sockaddr_in &peer)
: sockfd(sock), svr_ptr(ptr), addr(peer)
{
}
int SockFd() { return sockfd; }
TcpServer GetServer() { return svr_ptr; }
~ThreadData()
{
}
int sockfd;
TcpServer svr_ptr;
InetAddr addr;
};
class TcpServer : public nocopy
{
public:
static void *HandlerRequest(void *args) // 包含this指针,
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->GetServer()->Service(td->SockFd());
//解决了,函数是static的,但内部使用了非静态成员
close(td->SockFd());
delete td;
return nullptr;
}
}
使用线程安全的地址转换函数:
cpp
char* inet_ntoa(struct in_addr in)
我们之前文章使用的是 inet_ntoa 来进行网络字节序列的 IP 到 字符串的转换,返回的是字符串地址,那么字符串在哪里,inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:
因为 inet_ntoa 把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果。它返回指向静态缓冲区的指针,通常不可重入、非线程安全,连续调用会互相覆盖。
所以推荐使用 inet_ntop, 是把二进制 IP 地址转换成字符串的函数,可以看作 inet_ntop的线程安全、可重入、支持 IPv4/IPv6 替代品。1.网络转本机序列,2.字节风格ip转字符串风格ip
cpp
const char* inet_ntop(int af,const void* src, char* dst, socklen_t size);
- af:地址族,AF_INET 或 AF_INET6
- src:指向 struct in_addr 或 struct in6_addr
- dst:调用者提供的输出缓冲区
- size:缓冲区 dst 大小
返回值:成功:返回 dst 指针。失败:返回 NULL,并设置 errno。
cpp
char ipbuffer[64];
inet_ntop(AF_INET,&addr.sin_addr,ipbuffer,sizeof(ipbuffer));//
5.通过线程池处理
普通函数:名字会退化成指针,成员函数:名字不会退化成指针 TcpServer::Service 这不是一个指针,也不是一个可调用对象,只是一个"限定名"。在 C++ 里,成员函数的限定名不会自动退化成成员函数指针,你必须显式用 & 取地址,所以 bind 一个参数由重载的情况下这么写 void (TcpServer::*)(int, InetAddr)。而如果是不同函数不用加 &,直接写函数名称就行。所以在bind时需要加 &。
不能让我们的服务器提供长服务,否则会一直占用一个线程。
cpp
// v5 线程池 --- 不能让我们的服务器提供长服务,以为会一直占用一个线程
InetAddr addr(peer);
// 1.lambda
task_t t = [this, sockfd, addr]()
{
Service(sockfd, addr);
};
// 2.bind
// 要注意重载显示指出来,
// 成员函数的限定名不会自动退化成成员函数指针,你必须显式用 & 取地址
// task_t t = std::bind(
// static_cast<void(TcpServer::*)(int,InetAddr)>(&TcpServer::Service),
// this,
// sockfd,
// addr);
ThreadNs::ThreadPool<task_t>::GetInstance()->Push(t);
应该调用这样的函数,执行完就退出,最后关闭对应的套接字。
void Routine(int sockfd,InetAddr addr)//路由
{
funcs["defaultService"](sockfd,addr);
std::string type = Read(sockfd);
lg.LogMessage(Debug,"%s select %s\n", addr.PrintDebug().c_str(),type.c_str());
if(type == "ping")
funcs[type](sockfd, addr);
else if(type == "translate")
funcs[type](sockfd,addr);
else if(type == "transform")
funcs[type](sockfd,addr);
else
DefaultService(sockfd,addr);
//线程这里就会退出
close(sockfd);//处理完关闭相关套接字
}
未来的服务器时部署在你的云服务器上的,你怎么知道你的服务未来在任何一个时刻,都是健康的呢?我们可以每隔比如30s向我们的服务器发送最小服务请求,如果得到了回复,说明我们的服务是正常的,这种机制称之为心跳机制。我们可以问client -> client,反向的server->client
cpp
void Ping(int sockfd, InetAddr addr)
{
// tcp 面向字节流与管道和文件特性相同,可以read,write来处理
lg.LogMessage(Debug,"%s select %s success, fd :%d\n",
addr.PrintDebug().c_str(),"ping",sockfd);
char buffer[1024];
// read 大于0,文件结尾,-1出错
size_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << addr.PrintDebug() << " # " << buffer << std::endl;
std::string echo_string = "server echo# Pong";
write(sockfd, echo_string.c_str(), echo_string.size()); // 读到数据并返回
}
else if (n == 0) // read读到0表示读到文件结尾(对端关闭连接,没有关闭会阻塞)
{
lg.LogMessage(Info, "client quit...\n");
}
else
{
lg.LogMessage(Error, "accept socket error,errno code:%d,error string%s\n",
errno, strerror(errno));
}
}
6.守护进程
IO类函数,write/read 其实他们已经帮我们做了转网络序列。
udp:用户数据包 tcp:面向字节流,我们这里看起来没区别,尤其是网络IO的时候,用read,write,但目前的代码其实都是有Bug的!!!
- 面向数据报,数据和数据是有边界的,用的是sendto,recvfrom。sendto 发一次,一定对应对端recvfrom一次。
- 面向字节流,write 可能写了一次或多次,接收方可能一次就全部收完,也可能分多次读完,与发送发无关。如果一次读来多个,需要由用户分开处理。 而UDP数据报边界是很明显的,不再由用户处理
所以,tcp中要正确的读取,不出现错误,必须结合用户协议 。我们的网络服务,不能在bash中以前台进程的方式运行,真正的服务器,必须在linux后台 ,以 守护进程(精灵进程) 的方式进行运行。
除了进程的概念,还有进程组 PGID,作业,会话 SID。
命令行上执行的进程父进程都是bash,然后 PGID 就是进程组ID,SID是会话 ID;TPGID,t是终端,p就是进程,G是组,一般和PGID一样,它是当前前台进程组的 ID进程没有控制终端,或者终端没有前台进程组,TPGID 通常显示 -1;TTY,就是打开该进程的终端设备是谁,UID表示用户身份。PID 与 PGID 相同说明是一个自成进程组,进程组默认一定是在一个会话中的。兄弟进程可以通过匿名管道通信,同时启动多个进程,可以是属于同一个进程组的,组ID一般是多个进程中的第一个。
所以每次我们登录Linux,OS会给登录用户提供一个bash,提供一个终端(ssh),给用户提供命令行解析服务,叫做一个会话,会话与会话之间是具有隔离性的,在命令行中启动的所有进程,最终默认都是在当前会话内部的一个进程组(可以是一个进程自成进程组)。
一个用户可以有多个终端、多个 Bash、多个会话,它们通常互相独立,但共享同一个用户身份和权限。
一个终端里的一次"作业",通常就是一个进程组。比如运行 cat file | grep foo,这两个进程就属于同一个进程组。子进程默认继承父进程的进程组,通过 setpgid() 主动加入。
任何时刻,一个会话内部,可以存在很多个进程组(用户级任务),命令行一起执行的进程属于一个进程组,但是默认任何时刻,只允许一个进程组在前台(前台进程组),就是和终端相关和键盘相关。通过运行不同的进程**& 放到后台** ,可以创建多个进程组,使用 jobs 可以查看当前会话内部所有的进程组,fg 任务编号便可以放到前台 ,ctrl + z 再暂停放到后台 ,bg + 任务编号 让他再次运行。
进程组和用户级任务是一个概念,进程组就是来完成这些任务的,用户级任务只不过是用户层概念。会话和会话之间具有隔离性,用户退出,会话就会被释放,内部的进程组就会被释放。我们想让进程组不受用户登录和注销的影响,我们就需要把我们的进程变成守护进程。
守护进程是一个独立的会话,不隶属于任何一个 bash 会话。这个会话里只有它自己一个进程组,并以自己的进程组 ID(PGID),当作会话 ID(SID)。
cpp
pid_t setsid(void);
如果成功,返回调用者的PID,也就是自己的PID,所以PID,PGID,SID相同,失败返回-1,错误码被设置。但如果要创建一个新的会话,要求当前进程不能是进程组组长。组长一般是多个进程的第一个,如果你只有一个进程,自成进程组,那组长就是你自己,所以一般要创建子进程,让父进程直接退出,后面代码都有子进程执行,因为父子进程代码共享。所以守护进程一定是孤儿进程。
cpp
//1.忽略可能引起程序异常退出的信号
signal(SIGCHLD,SIG_IGN);
signal(SIGPIPE,SIG_IGN);
//2.退出父进程,自己不成为组长
if(fork() > 0)
{
//父进程
exit(0);
}
//3.子进程自己成为新会话
setsid();
更改工作目录
cpp
//4.每一个进程都有直接的CWD,
//当前工作路径,是否更改当前进程的cwd更改成为 / 根目录
if(ischdir) chdir(root);
任何一款Linux操作系统,都提供 /dev/null 的字符设备,凡是往这里写的任何东西,都自动被丢弃,凡是从这里读取,都会读到空,最大的特点是抛弃一切。已经变成守护进程,不需要再和用户的输入输出相关联了,我们使用 dup2 对文件重定向。
cpp
if(isclose)
{
close(0);close(1);close(2);
}
else
{
int fd = open(dev_null, O_RDWR);
if (fd > 0)
{
dup2(fd, 0); // 将fd粘贴到0,1,2
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
观察结果:
和终端无关,TTY是?,重新登陆进程还在。
通过kill 我们可以直接杀掉进程,也可以通过发送信号捕捉,把资源安全关闭,保存下数据再退出。
1.系统有没有提供把进程守护进程化的方法呢?
nochdir 为0,更改工作目录到 根目录,noclose设置为0,标准输入,标准输出,标准错误都设置到 /dev/null,否则什么都不变。
2.我们写的 tcp_server 和 udp_server 如何守护进程化
多线程程序 fork 后子进程只保留调用 fork 的那个线程。所以我们在 fork 前的父进程不要创建线程、不要持有复杂锁。所以daemon 函数调用要放在服务端创建创建多线程前面。
cpp
daemon(false,false);//守护进程化,放在 socket 前面
std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);
tsvr->Init();
lg.Enable(ClassFile);//日志打印重定向
tsvr->Start();
TCP 建立连接要 三次握手,断开连接要 四次挥手,我们,底层就需要两个系统进行三次握手,断开连接因为双方都需要调用close,
三次握手:
- 客户端发 SYN,说:我想连接,我的初始序列号是 x。
- 服务端回 SYN+ACK,说:收到,我的初始序列号是 y,我确认你的 x。
- 客户端回 ACK,说:收到你的 y,连接建立。(确保可靠)
四次挥手:
- A 发 FIN,只表示 A 不再发送数据,但 A 还能接收数据。
- B 收到 FIN 后,先回 ACK,表示"我知道你不发了"。
- 但 B 可能还有数据没发完,不能立刻关闭自己的发送方向。
- 等 B 数据发完,再发自己的 FIN
本篇结束!
