
目录1. 对一个本地信息进行返回1.1 服务端1.1.1 创建 socket 文件描述符1.1.2 填充本地网络信息并bind1.1.3 listen 开始监听socket (TCP, 服务器)1.1.4 accept 接收请求 (TCP, 服务器)1.1.5 处理消息。1.2 客户端1.2.1 connect 建立连接 (TCP, 客户端)2.通过孙子进程实现处理消息3.进程信号版处理客户消息4.多线程处理客户消息5.通过线程池处理6.守护进程1. 对一个本地信息进行返回1.1 服务端1.1.1 创建 socket 文件描述符创建 socket 文件描述符指向套接字对象。成功时返回一个文件描述符失败时返回 -1并设置对应的错误码。#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 是流式套接字通常用于 TCPSOCK_DGRAM 是面向数据报的套接字用于无连接的 UDP。此外还有 SOCK_RAW 底层原始套接字。第三个参数用于指定使用 TCP 还是 UDP 协议在网络通信中前两个参数固定后第三个参数缺省为 0 即可。1.1.2 填充本地网络信息并bind成功返回0失败返回-1并设置全局变量errnostruct 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, 服务器)客户端和服务器双方要进行数据通信必须先建立连接。通常由客户端主动发起连接而服务器则需要一直等待连接的到来因此要将服务器设置为监听状态。#include sys/types.h #include sys/socket.h int listen(int socket, int backlog);将套接字设置为监听状态即创建出的服务器套接字。第二个参数我们之后讲原理时再详细说明现在先设为 5。成功返回 0失败返回 -1并设置错误码。1.1.4 accept 接收请求 (TCP, 服务器)int accept(int socket, struct sockaddr* address, socklen_t* address_len);后两个参数就是输入输出型参数获取客户端的 ip 地址端口号等信息与 udp 中 recvfrom 中的两个参数相同。accept默认会阻塞但取决于监听套接字是“阻塞模式”还是“非阻塞模式”。成功返回新的套接字文件描述符原来的 Listenfdsocket继续用于监听继续 accept 其他客户端。失败返回 -1并设置错误码。也就是使用新的 fd 和 client 进行数据通信。1.1.5 处理消息。TCP 是面向字节流与管道和文件特性相同可以read,write来处理read返回 0 表示读到文件结尾对端关闭连接如果只是对端没有关闭会阻塞不会有返回值。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显示成数字形式llisten监听状态t:tcpp:process; 显示出处于监听状态的 tcp 进程。1.2 客户端inet_pton 把字符串形式的 IP 地址转换成二进制网络字节序地址IPv4就会转化为4字节的网络字节序列。af 地址簇src 原字符串 IP 地址dst 输出缓冲区。IPv4 传struct in_addr*IPv6 传 struct in6_addr*。#include arpa/inet.h int inet_pton(int af, const char* src, void* dst);//示例inet_pton(AF_INET,serverip.c_str(),server.sin_addr);1.2.1 connect 建立连接 (TCP, 客户端)int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);连接成功返回0会自动 bind IP地址端口号。连接失败返回-1。然后我们通过 write 写入如果服务端关了这里会产生一个sigpipe的信号但对端关闭这里也会写入成功因为这里是写到客户端自己进程的sockfd中了。#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(),amp;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-gt; 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 成功的返回值。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 进行忽略子进程退出的时候会自动释放自己的资源。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 时可能已经晚了确定的是地址不是地址里面保存的值。int sockfd; while (1) { sockfd accept(listenfd, ...); pthread_create(tid, NULL, worker, sockfd); }正确做法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 任务。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_castThreadData *(args); td-GetServer()-Service(td-SockFd()); //解决了函数是static的但内部使用了非静态成员 close(td-SockFd()); delete td; return nullptr; } }使用线程安全的地址转换函数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转字符串风格ipconst char* inet_ntop(int af,const void* src, char* dst, socklen_t size);af地址族AF_INET 或 AF_INET6src指向 struct in_addr 或 struct in6_addrdst调用者提供的输出缓冲区size缓冲区 dst 大小返回值成功返回dst指针。失败返回NULL并设置errno。char ipbuffer[64]; inet_ntop(AF_INET,addr.sin_addr,ipbuffer,sizeof(ipbuffer));//5.通过线程池处理普通函数名字会退化成指针成员函数名字不会退化成指针 TcpServer::Service 这不是一个指针也不是一个可调用对象只是一个“限定名”。在 C 里成员函数的限定名不会自动退化成成员函数指针你必须显式用 取地址所以 bind 一个参数由重载的情况下这么写 void (TcpServer::*)(int, InetAddr)。而如果是不同函数不用加 直接写函数名称就行。所以在bind时需要加 。不能让我们的服务器提供长服务否则会一直占用一个线程。// v5 线程池 --- 不能让我们的服务器提供长服务以为会一直占用一个线程 InetAddr addr(peer); // 1.lambda task_t t [this, sockfd, addr]() { Service(sockfd, addr); }; // 2.bind // 要注意重载显示指出来 // 成员函数的限定名不会自动退化成成员函数指针你必须显式用 取地址 // task_t t std::bind( // static_castvoid(TcpServer::*)(int,InetAddr)(TcpServer::Service), // this, // sockfd, // addr); ThreadNs::ThreadPooltask_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-clientvoid 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的面向数据报数据和数据是有边界的用的是sendtorecvfrom。sendto 发一次一定对应对端recvfrom一次。面向字节流write 可能写了一次或多次接收方可能一次就全部收完也可能分多次读完与发送发无关。如果一次读来多个需要由用户分开处理。 而UDP数据报边界是很明显的不再由用户处理所以tcp中要正确的读取不出现错误必须结合用户协议。我们的网络服务不能在bash中以前台进程的方式运行真正的服务器必须在linux后台以守护进程精灵进程的方式进行运行。除了进程的概念还有进程组 PGID作业会话 SID。命令行上执行的进程父进程都是bash然后 PGID 就是进程组IDSID是会话 IDTPGIDt是终端p就是进程G是组一般和PGID一样它是当前前台进程组的 ID进程没有控制终端或者终端没有前台进程组TPGID 通常显示 -1TTY就是打开该进程的终端设备是谁UID表示用户身份。PID 与 PGID 相同说明是一个自成进程组进程组默认一定是在一个会话中的。兄弟进程可以通过匿名管道通信同时启动多个进程可以是属于同一个进程组的组ID一般是多个进程中的第一个。所以每次我们登录LinuxOS会给登录用户提供一个bash提供一个终端ssh给用户提供命令行解析服务叫做一个会话会话与会话之间是具有隔离性的在命令行中启动的所有进程最终默认都是在当前会话内部的一个进程组可以是一个进程自成进程组。一个用户可以有多个终端、多个 Bash、多个会话它们通常互相独立但共享同一个用户身份和权限。一个终端里的一次“作业”通常就是一个进程组。比如运行 cat file | grep foo这两个进程就属于同一个进程组。子进程默认继承父进程的进程组通过 setpgid() 主动加入。任何时刻一个会话内部可以存在很多个进程组用户级任务命令行一起执行的进程属于一个进程组但是默认任何时刻只允许一个进程组在前台前台进程组就是和终端相关和键盘相关。通过运行不同的进程 放到后台可以创建多个进程组使用 jobs 可以查看当前会话内部所有的进程组fg 任务编号便可以放到前台ctrl z 再暂停放到后台bg 任务编号 让他再次运行。进程组和用户级任务是一个概念进程组就是来完成这些任务的用户级任务只不过是用户层概念。会话和会话之间具有隔离性用户退出会话就会被释放内部的进程组就会被释放。我们想让进程组不受用户登录和注销的影响我们就需要把我们的进程变成守护进程。守护进程是一个独立的会话不隶属于任何一个 bash 会话。这个会话里只有它自己一个进程组并以自己的进程组 ID(PGID)当作会话 IDSID。pid_t setsid(void);如果成功返回调用者的PID也就是自己的PID所以PIDPGIDSID相同失败返回-1错误码被设置。但如果要创建一个新的会话要求当前进程不能是进程组组长。组长一般是多个进程的第一个如果你只有一个进程自成进程组那组长就是你自己所以一般要创建子进程让父进程直接退出后面代码都有子进程执行因为父子进程代码共享。所以守护进程一定是孤儿进程。//1.忽略可能引起程序异常退出的信号 signal(SIGCHLD,SIG_IGN); signal(SIGPIPE,SIG_IGN); //2.退出父进程自己不成为组长 if(fork() 0) { //父进程 exit(0); } //3.子进程自己成为新会话 setsid();更改工作目录//4.每一个进程都有直接的CWD, //当前工作路径是否更改当前进程的cwd更改成为 / 根目录 if(ischdir) chdir(root);任何一款Linux操作系统都提供 /dev/null 的字符设备凡是往这里写的任何东西都自动被丢弃凡是从这里读取都会读到空最大的特点是抛弃一切。已经变成守护进程不需要再和用户的输入输出相关联了我们使用 dup2 对文件重定向。if(isclose) { close(0);close(1);close(2); } else { int fd open(dev_null, O_RDWR); if (fd 0) { dup2(fd, 0); // 将fd粘贴到012 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 函数调用要放在服务端创建创建多线程前面。daemon(false,false);//守护进程化放在 socket 前面 std::unique_ptrTcpServer tsvr make_uniqueTcpServer(port); tsvr-Init(); lg.Enable(ClassFile);//日志打印重定向 tsvr-Start();TCP 建立连接要 三次握手断开连接要 四次挥手我们底层就需要两个系统进行三次握手断开连接因为双方都需要调用close三次握手客户端发 SYN说我想连接我的初始序列号是 x。服务端回 SYNACK说收到我的初始序列号是 y我确认你的 x。客户端回 ACK说收到你的 y连接建立。确保可靠四次挥手A 发 FIN只表示 A 不再发送数据但 A 还能接收数据。B 收到 FIN 后先回 ACK表示“我知道你不发了”。但 B 可能还有数据没发完不能立刻关闭自己的发送方向。等 B 数据发完再发自己的 FIN本篇结束
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。