本文学习于:C语言技术网(www.freecplus.net),并加以自己的一些理解和复现,如有侵权会删除。
现在把 C++ 基础知识,算法,也学习完成基础计算机网络知识,同时也对操作系统,数据结构进行了简要的学习。
学习网络编程,跟着视频 B 站 UP 主 C 语言技术网,《C/C++ 网络编程,从 socket 到 epoll 》视频学习,同时也根据这个网址进行学习,网址如下:http://www.freecplus.net/44e059cca66042f0a1286eb188c51480.html,我只是为了学习记录,如果有侵权立刻删除,里面程序没有做任何改动,自己理解部分会在下面标注。听说网络编程 C/C++ 是最难掌握的技术,要求掌握信号、多进程、多线程知识,今天就来学习,然后理解它。这个系列视频从最基础的 socket 讲起,然后是多进程/多线程网络服务程序开发,到 I/O 复用(select、poll 和 epoll) 知识。
在学习项目过程中,仔细对了里面源码进行学习,对基础知识不理解,不系统,一点一点的查阅,效率相当慢,而且不能理解整个架构。因此,静下心来把这个课程过一遍,实践中学习。
socket 就是套接字,运行在计算机中的两个程序通过 socket 建立起一个通道,数据在通道中传输。socket 把复杂的 TCP/IP 协议族隐藏起来了,只要用好 socket 相关函数,就可以完成网络通信。
socket 提供了流(stream)和数据包(datagram)两种通信机制。stream socket 基于 TCP 协议,是一个有序,可靠,双向字节流的通道,传输数据不会丢失、不重复,顺序也不会乱。datagram socket 是基于 UDP 协议,不需要建立和维持连接,可能会丢失或者错乱。
简单的 socket 通讯流程,
客户端: socket() -> connect() -> send()/recv() -> close() 1. 创建流式 socket 2. 向服务器发起连接请求 3. 发送/接收数据 4. 关闭 socket 连接,释放资源 服务端 socket() -> bind() -> listen() -> accept() -> recv()/send() -> close() 1. 创建流式 socket 2. 指定通信的 ip 地址和端口 3. 把 socket 设置为监听模式 4. 接受客户端连接 5. 接受/发送数据 6.关闭 socket 连接,释放资源
下面学习一个服务器和客户通信的程序,但是程序执行之前,需要满足在运行程序之前,必须保证服务器的防火墙已经开通了网络访问策略(云服务器还需要登录云控制平台开通访问策略)。 ---- 因此需要安装防火墙,还有打开防火墙,打开端口。
安装设置防火墙步骤 :
/*安装防火墙部分*/ // 1. 开启管理员模式 su // 2. 安装防火墙 yum install firewall // 3. systemctl start firewalld // 4. 开启防火墙 systemctl enable firewalld // 5. 设置防火墙状态,如果进入文档编辑页面,按住 : ,然后输入 q 就退出了 systemctl status firewalld /*设置开启端口,查看状态部分*/ // 1. 查看防火墙工作状态 firewall-cmd --state // 2. 开启 5000 tcp 端口 firewall-cmd --zone=public --add-port=5000/tcp --permanent // 3. 重新加载,不然显示不出来端口号 firewall-cmd --reload // 4. 查看开启端口号 firewall-cmd --list-port
运行如下:
在配置好防火墙以后,开始运行客户端和服务器的程序源码。首先注意,在 linux 创建一个 cpp 文件如下
touch server.cpp touch client.cpp // 创建 makefile touch makefile
然后查看自己虚拟机 ip 地址的方式为:
ifconfig -a
在这里 192.168.201.129 就是我的 ip 地址。
然后打开 server.cpp 文件,代码如下,
/* * 程序名:server.cpp,此程序用于演示socket通信的服务端 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> int main(int argc,char *argv[]) { if (argc!=2) { printf("Using:./server port\nExample:./server 5005\n\n"); return -1; } // 第1步:创建服务端的socket。 int listenfd; if ( (listenfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); return -1; } // 第2步:把服务端用于通信的地址和端口绑定到socket上。 struct sockaddr_in servaddr; // 服务端地址信息的数据结构。 memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET。 servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。 //servaddr.sin_addr.s_addr = inet_addr("192.168.190.134"); // 指定ip地址。 servaddr.sin_port = htons(atoi(argv[1])); // 指定通信端口。 if (bind(listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 ) { perror("bind"); close(listenfd); return -1; } // 第3步:把socket设置为监听模式。 if (listen(listenfd,5) != 0 ) { perror("listen"); close(listenfd); return -1; } // 第4步:接受客户端的连接。 int clientfd; // 客户端的socket。 int socklen=sizeof(struct sockaddr_in); // struct sockaddr_in的大小 struct sockaddr_in clientaddr; // 客户端的地址信息。 clientfd=accept(listenfd,(struct sockaddr *)&clientaddr,(socklen_t*)&socklen); printf("客户端(%s)已连接。\n",inet_ntoa(clientaddr.sin_addr)); // 第5步:与客户端通信,接收客户端发过来的报文后,回复ok。 char buffer[1024]; while (1) { int iret; memset(buffer,0,sizeof(buffer)); if ( (iret=recv(clientfd,buffer,sizeof(buffer),0))<=0) // 接收客户端的请求报文。 { printf("iret=%d\n",iret); break; } printf("接收:%s\n",buffer); strcpy(buffer,"ok"); if ( (iret=send(clientfd,buffer,strlen(buffer),0))<=0) // 向客户端发送响应结果。 { perror("send"); break; } printf("发送:%s\n",buffer); } // 第6步:关闭socket,释放资源。 close(listenfd); close(clientfd); }
然后打开 client.cpp 文件
/* * 程序名:client.cpp,此程序用于演示socket的客户端 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> int main(int argc,char *argv[]) { if (argc!=3) { printf("Using:./client ip port\nExample:./client 127.0.0.1 5005\n\n"); return -1; } // 第1步:创建客户端的socket。 int sockfd; if ( (sockfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); return -1; } // 第2步:向服务器发起连接请求。 struct hostent* h; if ( (h = gethostbyname(argv[1])) == 0 ) // 指定服务端的ip地址。 { printf("gethostbyname failed.\n"); close(sockfd); return -1; } struct sockaddr_in servaddr; memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(atoi(argv[2])); // 指定服务端的通信端口。 memcpy(&servaddr.sin_addr,h->h_addr,h->h_length); if (connect(sockfd, (struct sockaddr *)&servaddr,sizeof(servaddr)) != 0) // 向服务端发起连接清求。 { perror("connect"); close(sockfd); return -1; } char buffer[1024]; // 第3步:与服务端通信,发送一个报文后等待回复,然后再发下一个报文。 for (int ii=0;ii<3;ii++) { int iret; memset(buffer,0,sizeof(buffer)); sprintf(buffer,"这是第%d个超级女生,编号%03d。",ii+1,ii+1); if ( (iret=send(sockfd,buffer,strlen(buffer),0))<=0) // 向服务端发送请求报文。 { perror("send"); break; } printf("发送:%s\n",buffer); memset(buffer,0,sizeof(buffer)); if ( (iret=recv(sockfd,buffer,sizeof(buffer),0))<=0) // 接收服务端的回应报文。 { printf("iret=%d\n",iret); break; } printf("接收:%s\n",buffer); } // 第4步:关闭socket,释放资源。 close(sockfd); }
接着打开 makefile 文件,输入
all:client server client:client.cpp g++ -g -o client client.cpp server:server.cpp g++ -g -o server server.cpp
最后就到了运行他们的环节,首先在 linux 开启两个终端,
// 1. 选择一个终端,先进行编译,使用 make 指令 make /*终端 1*/ // 对防火墙进行检测和开启 systemctl start firewalld systemctl enable firewalld firewall-cmd --state firewall-cmd --zone=public --add-port=5000/tcp --permanent firewall-cmd --reload firewall-cmd --list-port // 运行客户端程序,用 tcp 5000 端口号 ./client 127.0.0.1 5000 /*终端 2*/ // 运行服务器程序,用 tcp 5000 端口号 ./server 5000
结果如下
对程序进行学习,里面基本都是固定格式,需要修改的很少,所以就自己看和理解了。因为里面很多是库文件宏定义或者库函数,因此先记固定格式,熟悉了以后慢慢修改尝试应用。里面提到的我就按照上面学习了,其网址如下:
http://www.freecplus.net/0047ac4059b14d52bcc1d4df6ae8bb83.html
现在开始自己的分析了,其实刚开始我是存在疑问的,为什么任意 ip 都可以通信,后来看了源代码是这一句,
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。 //servaddr.sin_addr.s_addr = inet_addr("192.168.190.134"); // 指定ip地址。
然后记录觉得重要的几点,
1. socket()函数的返回值其本质是一个文件描述符,是一个整数。 2. 两个重要的发送和接收函数 ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv(int sockfd, void *buf, size_t len, int flags); sockfd 为建立的 socket,buf 为发送和接收的缓存,flags = 0 函数返回已发送的字符数。出错时返回-1,错误信息errno被标记。 3. 对服务端来说,有两个socket,一个是用于监听的socket,还有一个就是客户端连接成功 后,由accept函数创建的用于与客户端收发报文的socket。 4. 申请 socket 资源 int socket(int domain, int type, int protocol); domain 协议族,宏定义; type 指定类型,宏定义; protocol 传输协议方式 返回值:成功则返回一个socket,失败返回-1,错误原因存于errno 中。 第一个参数只能填AF_INET,第二个参数只能填SOCK_STREAM,第三个参数只能填0。 5. 把ip地址或域名转换为hostent 结构体表达的地址。 struct hostent *gethostbyname(const char *name); name:域名或者主机名; 返回值:如果成功,返回一个hostent结构指针,失败返回NULL。 gethostbyname只用于客户端。 6. 向服务器发起连接请求。 int connect(int sockfd, struct sockaddr * serv_addr, int addrlen); 函数说明:connect函数用于将参数sockfd 的socket 连至参数serv_addr 指定的服务 端,参数addrlen为sockaddr的结构长度。 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 connect函数只用于客户端。 7. 服务端把用于通信的地址和端口绑定到socket上。 int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen); 参数sockfd,需要绑定的socket。 参数addr,存放了服务端用于通信的地址和端口。 参数addrlen表示addr结构体的大小。 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 8. listen函数把主动连接socket变为被动连接的socket,使得这个socket可以接受其它 socket的连接请求,从而成为一个服务端的socket。 int listen(int sockfd, int backlog); 参数sockfd是已经被bind过的socket。 参数backlog,这个参数涉及到一些网络的细节,比较麻烦,填5、10都行,一般不超过30。 当调用listen之后,服务端的socket就可以调用accept来接受客户端的连接请求。 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 9. 服务端接受客户端的连接。 int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 参数sockfd是已经被listen过的socket。 参数addr用于存放客户端的地址信息,用sockaddr结构体表达,如果不需要客户端的地址,可 以填0。 参数addrlen用于存放addr参数的长度,如果addr为0,addrlen也填0。 accept函数等待客户端的连接,如果没有客户端连上来,它就一直等待,这种方式称之为阻 塞。
对于两个结构体定义
struct sockaddr_in{ short sin_family;/*Address family一般来说AF_INET(地址族)PF_INET(协议族)*/ unsigned short sin_port;/*Port number(必须要采用网络数据格式,普通数字可以用htons()函数转换成网络数据格式的数字)*/ struct in_addr sin_addr;/*IP address in network byte order(Internet address)*/ unsigned char sin_zero[8];/*Same size as struct sockaddr没有实际意义,只是为了 跟SOCKADDR结构在内存中对齐*/ }; /*该结构记录主机的信息,包括主机名、别名、地址类型、地址长度和地址列表。*/ struct hostent{ char * h_name;/*地址的正式名称*/ char ** h_aliases;/* 空字节-地址的预备名称的指针*/ short h_addrtype;/*地址类型; 通常是AF_INET*/ short h_length; char ** h_addr_list; #define h_addr h_addr_list[0]; }; char *h_name 表示的是主机的规范名。例如 www.google.com 的规范名其实是 www.l.google.com char **h_aliases 表示的是主机的别名。 www.google.com 就是google他自己的别名。有的时候,有的主机可能有好几个别名,这些,其实都是为了易于用户记忆而为自己的网站多取的名字。 int h_addrtype 表示的是主机ip地址的类型,到底是ipv4(AF_INET),还是ipv6(AF_INET6) int h_length 表示的是主机ip地址的长度 int **h_addr_lisst 表示的是主机的ip地址,注意,这个是以网络字节序存储的。千万不要直接用printf带%s参数来打这个东西,会有问题。所以到真正需要打印出这个IP的话,需要调用inet_ntop()。 const char *inet_ntop(int af, const void *src, char *dst, socklen_t cnt) : 这个函数,是将类型为af的网络地址结构src,转换成主机序的字符串形式,存放在长度为cnt的字符串中。 这个函数,其实就是返回指向dst的一个指针。如果函数调用错误,返回值是NULL。
注意一点,对于main 函数中两个参数的意义,可以参考下面的网址进行学习:https://blog.csdn.net/sun1314_/article/details/71271641。
通过对源代码学习理解以后,结构体可以自己百度查一下,代码可以理解作配置内容,用户可以修改的是这个部分,
server.cpp // 第5步:与客户端通信,接收客户端发过来的报文后,回复ok。 char buffer[1024]; // 注意是 while ,一直接收完毕 while (1) { int iret; memset(buffer,0,sizeof(buffer)); if ( (iret=recv(clientfd,buffer,sizeof(buffer),0))<=0) // 接收客户端的请求报文。 { // 发送完毕后会执行这一句 printf("iret=%d\n",iret); break; } printf("接收:%s\n",buffer); strcpy(buffer,"ok"); // 改变发送的字符或者数据 if ( (iret=send(clientfd,buffer,strlen(buffer),0))<=0) // 向客户端发送响应结果。 { perror("send"); break; } printf("发送:%s\n",buffer); } // 第6步:关闭socket,释放资源。 close(listenfd); close(clientfd); ---------------------------------------------------------------------- 分界线 ---------------------------------------------------------------------- client.cpp char buffer[1024]; // 第3步:与服务端通信,发送一个报文后等待回复,然后再发下一个报文。 // 发送完毕 for (int ii=0;ii<3;ii++) { int iret; memset(buffer,0,sizeof(buffer)); // 对 buffer 写入数据,并发送,可以修改 sprintf(buffer,"这是第%d个超级女生,编号%03d。",ii+1,ii+1); if ( (iret=send(sockfd,buffer,strlen(buffer),0))<=0) // 向服务端发送请求报文。 { perror("send"); break; } printf("发送:%s\n",buffer); memset(buffer,0,sizeof(buffer)); // 接收数据,存到 buffer 里面 if ( (iret=recv(sockfd,buffer,sizeof(buffer),0))<=0) // 接收服务端的回应报文。 { // 接收完毕,结束 printf("iret=%d\n",iret); break; } printf("接收:%s\n",buffer); } // 第4步:关闭socket,释放资源。 close(sockfd);
听课记录
使用 gdb 可以调试程序: yum install gdb // 安装gdb gdb server // 调试 server (gdb) set args 5005 // 设置参数 (gdb) run // 运行程序 (gdb) n // 跳一行 (gdb) p sockfd // 查看某个变量值 (gdb) q // 退出 其他都在尝试中练习
通过对上面的学习,对网络编程有了一定的了解,上面基本都是固定格式,要记住他们的通信规律和方法,按照模板修改就可以,后面继续学习。
再次声明,我仅仅是用作学习记录,再分享我的学习过程,如果侵权我立马撤回。函数声明如下
int socket(int domain, int type, int protocol); 参数说明: 1. domain:协议域,又称协议族(family)。常用的协议族有AF_INET、AF_INET6、 AF_LOCAL(或称AF_UNIX,Unix域Socket)、AF_ROUTE等。协议族决定了socket的地址类型,在 通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组 合、AF_UNIX决定了要用一个绝对路径名作为地址。 2. type:指定socket类型。常用的socket类型有SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、 SOCK_PACKET、SOCK_SEQPACKET等。流式socket(SOCK_STREAM)是一种面向连接的socket, 针对于面向连接的TCP服务应用。数据报式socket(SOCK_DGRAM)是一种无连接的socket,对应于 无连接的UDP服务应用。 3. protocol:指定协议。常用协议有IPPROTO_TCP、IPPROTO_UDP、IPPROTO_STCP、 IPPROTO_TIPC等,分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。 4. 返回值:成功则返回一个socket,失败返回-1,错误原因存于errno 中。 说了一大堆废话,第一个参数只能填AF_INET,第二个参数只能填SOCK_STREAM,第三个参数只能填 0。除非系统资料耗尽,socket函数一般不会返回失败。
缺省打开 socket 为 1024 ,这个是由系统设定决定的,搞线程压力测试需要注意。使用这个语句查看
ulimit -a
如下
字节顺序是指占内存多于一个字节类型的数据在内存中的存放顺序,一个 32 位整数由 4 个字节组成。内存中存储这 4 个字节有两个方法:一种是将低序字节存储在起始地址,称为小端字节序;另外是将高字节存储在起始地址,称为大端字节序。比如
将 0x12345678 大端字节序 高地址 低地址 78 56 34 12 小端字节序 高地址 低地址 12 34 56 78
注意:
网络字节序: 网络字节序是 TCP/IP 中规定好的一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而 保证数据在不同主机之间传输时能被正确解释。网络字节采用大端排序方式。 主机字节序: 不同的机器主机字节序不相同,与 CPU 设计有关,数据的顺序是由 CPU 决定的,与操作系统无关。 由于这个原因,不同体系结构的机器之间无法通信,所以要转换成一种约定的字节序,也就是网络字节 序。即使同一台机器上的两个进程(比如一个由于 C 语言,另外一个由 JAVA 编写)通信,也要考虑字 节序的问题(JAVA采用大段字节序) 网络字节序与主机字节序之间的转换函数: // 完成 16 位无符号数的相互转换 htons() // host to network short ntohs() // network to host short // 完成 32 位无符号数的相互转换 htonl() // host to network long ntohl() // network to host long TCP 协议中的主机地址和端口采用整数来表示: 192.168.190.134 // 小端方式 11000000 10101000 10111110 10000110 3232284294 ----> 十进制数存放 // 大端方式 --- 网络字节序方式 10000110 10111110 10101000 11000000 2260641984 ----> 十进制数存放
网络编程中,网络协议,IP地址,端口是采用一个结构体存放的,其结构体如下,
// 两个结构体字节一样,因此可以互相强制转换类型 // 这样存放存在的问题:用 14 字节存放操作比较麻烦 struct sockaddr{ unsigned short sa_family; // 地址类型,AF_xxx 2 字节 char sa_data[14]; // 14字节的端口和地址 }; struct sockaddr_in{ short int sin_family; //地址类型 - 2 字节 unsigned short int sin_port;//端口号 - 2 字节 struct in_addr sin_addr; // 地址 - 4 字节 unsigned char sin_zero[8]; // 为了保持与 struct sockaddr 一样的长度 - 8 字节 }; struct in_addr{ unsigned long s_addr; // 地址 };
对 IP 地址进行处理存储的结构体
struct hostent{ char * h_name; // 主机名 char ** h_aliases; // 主机所有别名构成的字符串数组,同一 IP 可绑定多个域名 int h_addrtype; // 主机的 IP 地址的类型,例如 IPv4(AD_INET)还是 IPv6 int h_length; // 主机的 IP 地址长度,IPv4 地址为 4 , IPv6地址为 16 char ** h_addr_list; // 主机的 IP 地址,以网络字节序存储 #define h_addr h_addr_list[0]; }; // gethostbyname 函数可以利用字符串格式的域名获得 IP 网络字节顺序地址 struct hostent *gethostbyname(const char * name);
在这里,像这样写,无论给域名,还是 IP 地址都可以解析
struct hostent* h; if ( (h = gethostbyname(argv[1])) == 0 ) // 指定服务端的ip地址。 { printf("gethostbyname failed.\n"); close(sockfd); return -1; } struct sockaddr_in servaddr; memset(&servaddr,0,sizeof(servaddr));
还有一些和结构体转换相对应的函数
1. int inet_aton(const *cp, struct in_addr *inp); 将一个字符串 IP 地址转换为一个 32 位的网络字节序 IP 地址。如果这个函数成功,函数的返回值 非零,如果输入地址不正确则会返回零。使用这个函数并没有错误码存在 errno 中,所以它的值会忽 略。 2. char *inet_ntoa(struct in_addr in); 把网络字节序转化为字符串的 IP 地址。 3. in_addr_t inet_addr(const char *cp); 把字符串 IP 地址转化为网络字节序。
bind函数把一个本地协议地址赋予一个套接字。对于网际协议,协议地址是32位的IPv4地址或是128位的IPv6地址与16位的TCP或UDP端口号的组合。服务端把用于通信的地址和端口绑定到socket上。函数声明如下:
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen); 1. 参数sockfd,需要绑定的socket。 2. 参数addr,存放了服务端用于通信的地址和端口。注意结构体要强制转化为 sockaddr 类型 3. 参数addrlen表示addr结构体的大小。 4. 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 如果绑定的地址错误,或端口已被占用,bind函数一定会报错,否则一般不会返回错误。
对几个函数的总结
1). 服务器在调用 listen() 之前,客户端不能向服务端发起连接请求的。 2). 服务端调用 listen() 函数后,服务端的 socket 开始监听客户端的连接。 3). 客户端调用 connect() 函数向服务端发起连接请求。 4). 在 TCP 底层,客户端和服务端握手后建立起通信通道,如果有多个客户请求,在服务端就会形成 一个已准备好的连接的队列。 5). 服务端调用 accept() 函数从队列中获取一个已准备好的连接,函数返回一个新的 socket , 新的 socket 用于与客户端通信,listen 的socket 只负责监听客户端的连接请求。
listen函数把主动连接socket变为被动连接的socket,使得这个socket可以接受其它socket的连接请求,从而成为一个服务端的socket。如果 socket 不进行 listen,那么就会连接错误。
int listen(int sockfd, int backlog); 返回:0-成功, -1-失败 1. 参数sockfd是已经被bind过的socket。socket函数返回的socket是一个主动连接的socket, 在服务端的编程中,程序员希望这个socket可以接受外来的连接请求,也就是被动等待客户端来连接。 由于系统默认时认为一个socket是主动连接的,所以需要通过某种方式来告诉系统,程序员通过调用 listen函数来完成这件事。 2. 参数backlog,这个参数涉及到一些网络的细节,比较麻烦,填5、10都行,一般不超过30。 3. 当调用listen之后,服务端的socket就可以调用accept来接受客户端的连接请求。 4. 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。
connect函数向服务器发起连接请求,声明如下
int connect(int sockfd, struct sockaddr * serv_addr, int addrlen); 1. 函数说明:connect函数用于将参数sockfd 的socket 连至参数serv_addr 指定的服务端,参 数addrlen为sockaddr的结构长度。 2. 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 3. connect函数只用于客户端。 如果服务端的地址错了,或端口错了,或服务端没有启动,connect一定会失败。
accept函数为服务端接受客户端的连接。对于多个 client ,建立通信时,accept 相当于从队列中依此接收 client 发送的消息,如果队列为空,则阻塞等待。其声明如下
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 1. 参数sockfd是已经被listen过的socket。 2. 参数addr用于存放客户端的地址信息,用sockaddr结构体表达,如果不需要客户端的地址,可以 填0。 3. 参数addrlen用于存放addr参数的长度,如果addr为0,addrlen也填0。 4. accept函数等待客户端的连接,如果没有客户端连上来,它就一直等待,这种方式称之为阻塞。 5. accept等待到客户端的连接后,创建一个新的socket,函数返回值就是这个新的socket,服务端 使用这个新的socket和客户端进行报文的收发。 6. 返回值:成功则返回0,失败返回-1,错误原因存于errno 中。 7. accept在等待的过程中,如果被中断或其它的原因,函数返回-1,表示失败,如果失败,可以重新accept。
三次握手图解如下,
其中 ESTABLISHED 代表握手成功,listen 完成以后,等待连接,变成 SYN_RECV 以后,可以接受发送数据。
可以通过这样查看对应端口状态 netstat -na|grep 5005 把 server.cpp 部分改为 // 第3步:把socket设置为监听模式。 if (listen(listenfd,5) != 0 ) { perror("listen"); close(listenfd); return -1; } sleep(1000); // 第4步:接受客户端的连接。 while(1){ clientfd=accept(listenfd,(struct sockaddr *)&clientaddr,(socklen_t*)&socklen); printf("客户端(%s)已连接。\n",inet_ntoa(clientaddr.sin_addr)); sleep(10); } 然后先启动服务端 ./server 5005 通过观察其一直在 listen 状态 然后启动多个客户端,都用 5005端口 ./client 127.0.0.1 5005
结果如下
可以看出,服务端与客户端都握手成功。 在 listen 和 connect 建立握手以后,等待 accept 数据发送和接收。
现在介绍两个函数,发送数据和接受数据函数,其函数声明如下。
recv 函数用于接收对端 socket 发送过来的数据。recv 函数用于接收对端通过 socket 发送过来的数据。不论是客户端还是服务端,应用程序都用 recv 函数接收来自TCP 连接的另一端发送过来数据。声明如下
ssize_t recv(int sockfd, void *buf, size_t len, int flags); 1. sockfd为已建立好连接的socket。 2. buf为用于接收数据的内存地址,可以是C语言基本数据类型变量的地址,也可以数组、结构体、字符串,只要是一块内存就行了。 3. len需要接收数据的长度,不能超过buf的大小,否则内存溢出。 4. flags填0, 其他数值意义不大。 5. 函数返回已接收的字符数。出错时返回-1,失败时不会设置errno的值。 如果socket的对端没有发送数据,recv函数就会等待,如果对端发送了数据,函数返回接收到的字符 数。出错时返回-1。如果socket被对端关闭,返回值为0。如果recv函数返回的错误(<=0),表示通 信通道已不可用。
send函数用于把数据通过socket发送给对端。不论是客户端还是服务端,应用程序都用send函数来向TCP连接的另一端发送数据。函数声明如下
ssize_t send(int sockfd, const void *buf, size_t len, int flags); 1. sockfd为已建立好连接的socket。 2. buf为需要发送的数据的内存地址,可以是C语言基本数据类型变量的地址,也可以数组、结构体、 字符串,内存中有什么就发送什么。 3. len需要发送的数据的长度,为buf中有效数据的长度。 4. flags填0, 其他数值意义不大。 5. 函数返回已发送的字符数。出错时返回-1,错误信息errno被标记。 注意,就算是网络断开,或socket已被对端关闭,send函数不会立即报错,要过几秒才会报错。如果 send函数返回的错误(<=0),表示通信链路已不可用。
send 函数也是有阻塞的,如果 buffer 被填满,接收端 recv 还没接收满足,那么就会阻塞。直到接收 recv 足够的时候,继续发送。
基本概念
分包:发送方发送字符串“helloworld”,接收方却收到两个字符串“hello”和“world”。 粘包:发送方发送两个字符串“hello”+“world”,接收方却一次性收到了“helloworld”。 但是 TCP 传输可以保证几点: 1. 顺序不变; 2. 分割的包中间不会插入其他数据。为解决分包和粘包问题,定义一份协议,常用方式为 报文长度 + 报文内容 0010helloworld 报文长度 ascii 码,二进制整数
关于 TCP 报文分包和粘包的情况,视频中有演示,我自己按照它的方法,在我的虚拟机上面运行,并没有出现杂乱的情况。不过 UP 主说后面会继续讲解,后面再继续理解。
socket 编程的函数很多,细节也很多,如果每个项目都从 socket 的函数开始编程,代码会非常繁琐。解决方法就是封装(造轮子)。
recv() 函数可能存在读取的报文不完整的情况,send() 也可能存在写入数据不完整的情况。因此写了两个函数了解这些问题,两个函数如下,
为了解决 TCP 分包和粘包的问题,用 TcpWrite 和 TcpRead 两个函数来解决问题
其源码如下,
/* * 程序名:book248.cpp,此程序用于演示用C++的方法封装socket服务端 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> class CTcpServer { public: int m_listenfd; // 服务端用于监听的socket int m_clientfd; // 客户端连上来的socket CTcpServer(); bool InitServer(int port); // 初始化服务端 bool Accept(); // 等待客户端的连接 // 向对端发送报文 int Send(const void *buf,const int buflen); // 接收对端的报文 int Recv(void *buf,const int buflen); ~CTcpServer(); }; int main() { CTcpServer TcpServer; if (TcpServer.InitServer(5005)==false) { printf("TcpServer.InitServer(5005) failed,exit...\n"); return -1; } if (TcpServer.Accept() == false) { printf("TcpServer.Accept() failed,exit...\n"); return -1; } printf("客户端已连接。\n"); char strbuffer[1024]; while (1) { memset(strbuffer,0,sizeof(strbuffer)); if (TcpServer.Recv(strbuffer,sizeof(strbuffer))<=0) break; printf("接收:%s\n",strbuffer); strcpy(strbuffer,"ok"); if (TcpServer.Send(strbuffer,strlen(strbuffer))<=0) break; printf("发送:%s\n",strbuffer); } printf("客户端已断开连接。\n"); } CTcpServer::CTcpServer() { // 构造函数初始化socket m_listenfd=m_clientfd=0; } CTcpServer::~CTcpServer() { if (m_listenfd!=0) close(m_listenfd); // 析构函数关闭socket if (m_clientfd!=0) close(m_clientfd); // 析构函数关闭socket } // 初始化服务端的socket,port为通信端口 bool CTcpServer::InitServer(int port) { m_listenfd = socket(AF_INET,SOCK_STREAM,0); // 创建服务端的socket // 把服务端用于通信的地址和端口绑定到socket上 struct sockaddr_in servaddr; // 服务端地址信息的数据结构 memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 本主机的任意ip地址 servaddr.sin_port = htons(port); // 绑定通信端口 if (bind(m_listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } // 把socket设置为监听模式 if (listen(m_listenfd,5) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } return true; } bool CTcpServer::Accept() { if ( (m_clientfd=accept(m_listenfd,0,0)) <= 0) return false; return true; } int CTcpServer::Send(const void *buf,const int buflen) { return send(m_clientfd,buf,buflen,0); } int CTcpServer::Recv(void *buf,const int buflen) { return recv(m_clientfd,buf,buflen,0); }
其源码如下,
/* * 程序名:book247.cpp,此程序用于演示用C++的方法封装socket客户端 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> // TCP客户端类 class CTcpClient { public: int m_sockfd; CTcpClient(); // 向服务器发起连接,serverip-服务端ip,port通信端口 bool ConnectToServer(const char *serverip,const int port); // 向对端发送报文 int Send(const void *buf,const int buflen); // 接收对端的报文 int Recv(void *buf,const int buflen); ~CTcpClient(); }; int main() { CTcpClient TcpClient; // 向服务器发起连接请求 if (TcpClient.ConnectToServer("127.0.0.1,5005) == false) { printf("TcpClient.ConnectToServer(\"127.0.0.1\",5005) failed,exit...\n"); return -1; } char strbuffer[1024]; for (int ii=0;ii<5;ii++) { memset(strbuffer,0,sizeof(strbuffer)); sprintf(strbuffer,"这是第%d个超级女生,编号%03d。",ii+1,ii+1); if (TcpClient.Send(strbuffer,strlen(strbuffer))<=0) break; printf("发送:%s\n",strbuffer); memset(strbuffer,0,sizeof(strbuffer)); if (TcpClient.Recv(strbuffer,sizeof(strbuffer))<=0) break; printf("接收:%s\n",strbuffer); } } CTcpClient::CTcpClient() { m_sockfd=0; // 构造函数初始化m_sockfd } CTcpClient::~CTcpClient() { if (m_sockfd!=0) close(m_sockfd); // 析构函数关闭m_sockfd } // 向服务器发起连接,serverip-服务端ip,port通信端口 bool CTcpClient::ConnectToServer(const char *serverip,const int port) { m_sockfd = socket(AF_INET,SOCK_STREAM,0); // 创建客户端的socket struct hostent* h; // ip地址信息的数据结构 if ( (h=gethostbyname(serverip)) == 0 ) { close(m_sockfd); m_sockfd=0; return false; } // 把服务器的地址和端口转换为数据结构 struct sockaddr_in servaddr; memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(port); memcpy(&servaddr.sin_addr,h->h_addr,h->h_length); // 向服务器发起连接请求 if (connect(m_sockfd,(struct sockaddr *)&servaddr,sizeof(servaddr))!=0) { close(m_sockfd); m_sockfd=0; return false; } return true; } int CTcpClient::Send(const void *buf,const int buflen) { return send(m_sockfd,buf,buflen,0); } int CTcpClient::Recv(void *buf,const int buflen) { return recv(m_sockfd,buf,buflen,0); }
它们建立连接,运行结果如下,
对网络程序,封装的意义如下,
采用C++封装的意义主要有以下几方面: 1) 把数据初始化的代码放在构造函数中; 2) 把关闭socket等释放资源的代码放在析构函数中; 3) 把socket定义为类的成员变量,类外部的代码根本看不到socket; 4) 代码更简洁,更安全(析构函数自动调用关闭socket,释放资源)。
关于进程的几个重要函数,
// 1. getpid库函数的功能是获取本程序运行时进程的编号。 pid_t getpid(); // 2. fork函数用于产生一个新的进程,函数返回值pid_t是一个整数,在父进程中,返回值是子进程编号,在子进程中,返回值是0。 pid_t fork(); linux 对进程的相关操作: ps 查看当前终端的进程。 ps -ef |grep book 查看系统全部的进程
子进程和父进程使用相同的代码段;子进程拷贝了父进程的堆栈段和数据段。子进程一旦开始运行,它复制了父进程的一切数据,然后各自运行,相互之间没有影响。 在父进程中定义的变量子进程中会复制一个副本,fork之后,子进程对变量的操作不会影响父进程,父进程对变量的操作也不会影响子进程。还需要注意,
1)进程的编号是系统动态分配的,相同的程序在不同的时间执行,进程的编号是不同的。 2)进程的编号会循环使用,但是,在同一时间,进程的编号是唯一的,也就是说,不管任何时间,系统 不可能存在两个编号相同的进程。
进程的数据空间是独立的,私有的,不能相互访问,但是在某些情况下进程之间需要通信来实现某功能或交换数据,包括:
1) 数据传输: 一个进程需要将它的数据发送给另一个进程。
2) 共享数据: 多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
3) 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如通知进程退出)。
4) 进程控制: 一个进程希望控制另一个进程的运行。
进程通信的方式分为以下几种,
1)管道:包括无名管道(pipe)及命名管道(named pipe),无名管道可用于具有父进程和子进程 之间的通信。命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲 缘关系进程间的通信。 2)消息队列(message):进程可以向队列中添加消息,其它的进程则可以读取队列中的消息。 3)信号(signal):信号用于通知其它进程有某种事件发生。 4)共享内存(shared memory):多个进程可以访问同一块内存空间。 5)信号量(semaphore):也叫信号灯,用于进程之间对共享资源进行加锁。 6)套接字(socket):可用于不同计算机之间的进程间通信。 应用经验: 1)管道太过时了,实在没什么应用价值,了解概念就行。 2)socket可以用于不同系统之间的进程通信,完全可以代替只能在同一系统中进程之间通信的管道和 消息队列。 3)信号的应用场景非常多,主要用于进程的控制,例如通知正在运行中的后台服务程序退出。 4)同一系统中,进程之间采用共享内存交换数据的效率是最高的,但是,共享内存没有加锁的机制,所 以经常与信号灯结合一起来使用,在高性能的网络服务端程序中,可以用共享内存作为的数据缓存 (cache)。 5)在企业IT系统内部,消息队列已经逐渐成为通信的核心手段,它具有低耦合、可靠投递、广播、流量 控制、一致性等一系列功能。当今市面上有很多主流的消息中间件有Redis、RabbitMQ、Kafka、 ActiveMQ、ZeroMQ,阿里巴巴自主开发RocketMQ等。
如果想让程序在后台运行,执行程序的时候,命令的最后面加“&”符号。程的数
比如之前运行客户端使用: ./client 127.0.0.1 5005 & 这样可以运行多个客户端 查看进程 ps -ef|grep client killall client 把进程杀死 或者 先用“ps -ef|grep client”找到程序的进程编号,然后用“kill 进程编号”。
还可以采用fork,主程序执行fork,生成一个子进程,然后父进程退出,留下子进程继续运行,子进程将由系统托管。这样也在后台增加一个程序
if (fork()>0) return 0;
signal信号是Linux编程中非常重要的部分,接下来将详细介绍信号的基本概念、实现和使用,和与信号的几个系统调用(库函数)。signal信号是进程之间相互传递消息的一种方法,信号全称为软中断信号,也有人称作软中断。
软中断信号(signal,又简称为信号)用来通知进程发生了事件。 进程之间可以通过调用kill库函数发送软中断信号。Linux内核也可能给进程发送信号,通知进程发生了某个事件(例如内存越界)。
注意,信号只是用来通知某进程发生了什么事件,无法给进程传递任何数据,进程对信号的处理方法有三种:
1)第一种方法是,忽略某个信号,对该信号不做任何处理,就象未发生过一样。
2)第二种是设置中断的处理函数,收到信号后,由该函数来处理。
3)第三种方法是,对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。
发出信号的原因很多,这里按发出信号的原因简单分类,以了解各种信号,参考链接:http://www.freecplus.net/eec5c39aa63b45ad946f1cc08134d9f9.html
signal库函数可以设置程序对信号的处理方式,如下,
sighandler_t signal(int signum, sighandler_t handler); 参数signum表示信号的编号。 参数handler表示信号的处理方式,有三种情况: 1)SIG_IGN:忽略参数signum所指的信号。 2)一个自定义的处理信号的函数,信号的编号为这个自定义函数的参数。 3)SIG_DFL:恢复参数signum所指信号的处理方法为默认值。 程序员不关心signal的返回值 Linux操作系统提供了kill命令向程序发送信号,C语言也提供了kill库函数,用于在程序中向其它进 程或者线程发送信号。 int kill(pid_t pid, int sig); 参数pid 有几种情况: 1)pid>0 将信号传给进程号为pid 的进程。 2)pid=0 将信号传给和目前进程相同进程组的所有进程,常用于父进程给子进程发送信号,注意,发 送信号者进程也会收到自己发出的信号。 3)pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信 息。 sig:准备发送的信号代码,假如其值为零则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。 返回值说明: 成功执行时,返回0;失败返回-1,errno被设为以下的某个值。 EINVAL:指定的信号码无效(参数 sig 不合法)。 EPERM:权限不够无法传送信号给指定进程。 ESRCH:参数 pid 所指定的进程或进程组不存在。
服务程序运行在后台,如果想让中止它,强行杀掉不是个好办法,因为程序被杀的时候,程序突然死亡,没有释放资源,会影响系统的稳定,用 Ctrl + C 中止与杀程序是相同的效果。信号的作用: 如果能向后台程序发送一个信号,后台程序收到这个信号后,调用一个函数,在函数中编写释放资源的代码,程序就可以有计划的退出, 安全而体面。
共享内存(Shared Memory) 就是允许多个进程访问同一个内存空间,是在多个进程之间共享和传递数据最高效的方式。操作系统将不同进程之间共享内存安排为同一段物理内存,进程可以将共享内存连接到它们自己的地址空间中,如果某个进程修改了共享内存中的数据,其它的进程读到的数据也将会改变,共享内存并未提供锁机制。如果要对共享内存的读/写加锁,可以使用信号灯。Linux中提供了一组函数用于操作共享内存,程序中需要包含以下头文件:
#include <sys/ipc.h> #include <sys/shm.h>
一些重要的函数,
shmget函数用来获取或创建共享内存,它的声明为: int shmget(key_t key, size_t size, int shmflg); 参数key是共享内存的键值,是一个整数,typedef unsigned int key_t,是共享内存在系统中的编号,不同共享内存的编号不能相同,这一点由程序员保证。key用十六进制表示比较好。 参数size是待创建的共享内存的大小,以字节为单位。 参数shmflg是共享内存的访问权限,与文件的权限一样,0666|IPC_CREAT表示全部用户对它可读写,如果共享内存不存在,就创建一个共享内存。 把共享内存连接到当前进程的地址空间。它的声明如下: void *shmat(int shm_id, const void *shm_addr, int shmflg); 参数shm_id是由shmget函数返回的共享内存标识。 参数shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。 参数shm_flg是一组标志位,通常为0。 调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1. 该函数用于将共享内存从当前进程中分离,相当于shmat函数的反操作。它的声明如下: int shmdt(const void *shmaddr); 参数shmaddr是shmat函数返回的地址。 调用成功时返回0,失败时返回-1. 删除共享内存,它的声明如下: int shmctl(int shm_id, int command, struct shmid_ds *buf); 参数shm_id是shmget函数返回的共享内存标识符。 参数command填IPC_RMID。 参数buf填0。 解释一下,shmctl是控制共享内存的函数,其功能不只是删除共享内容,但其它的功能没什么用,所以不介绍了。 注意,用root创建的共享内存,不管创建的权限是什么,普通用户无法删除。
一个例子程序如下,
/* * 程序名:book258.cpp,此程序用于演示共享内存的用法 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/shm.h> int main() { int shmid; // 共享内存标识符 // 创建共享内存,键值为0x5005,共1024字节。 if ( (shmid = shmget((key_t)0x5005, 1024, 0640|IPC_CREAT)) == -1) { printf("shmat(0x5005) failed\n"); return -1; } char *ptext=0; // 用于指向共享内存的指针 // 将共享内存连接到当前进程的地址空间,由ptext指针指向它 ptext = (char *)shmat(shmid, 0, 0); // 操作本程序的ptext指针,就是操作共享内存 printf("写入前:%s\n",ptext); sprintf(ptext,"本程序的进程号是:%d",getpid()); printf("写入后:%s\n",ptext); // 把共享内存从当前进程中分离 shmdt(ptext); // 删除共享内存 // if (shmctl(shmid, IPC_RMID, 0) == -1) // { printf("shmctl(0x5005) failed\n"); return -1; } }
运行结果如下,
因为在程序中没有把共享内存删除,所以每次运行时候,都会把进程号写入共享内存,开始共享内存是空的,后来不断填充并覆盖。可以用
ipcs -m // 查看共享内存 ipcrm -m 编号(shmid) // 手动删除共享内存
运行如下,
信号量(信号灯)本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。 它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、socket连接池、数据库连接池等),保证共享资源在一个时刻只有一个进程独享。信号量是一个特殊的变量,只允许进程对它进行等待信号和发送信号操作。 最简单的信号量是取值0和1的二元信号量,这是信号量最常见的形式。 通用信号量(可以取多个正整数值)和信号量集方面的知识比较复杂,应用场景也比较少。
Linux中提供了一组函数用于操作信号量,程序中需要包含以下头文件:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h>
相关函数如下,
semget函数用来获取或创建信号量,它的原型如下: int semget(key_t key, int nsems, int semflg); 1)参数key是信号量的键值,typedef unsigned int key_t,是信号量在系统中的编号,不同信号量的编号不能相同,这一点由程序员保证。key用十六进制表示比较好。 2)参数nsems是创建信号量集中信号量的个数,该参数只在创建信号量集时有效,这里固定填1。 3)参数sem_flags是一组标志,如果希望信号量不存在时创建一个新的信号量,可以和值IPC_CREAT做按位或操作。如果没有设置IPC_CREAT标志并且信号量不存在,就会返错误(errno的值为2,No such file or directory)。 4)如果semget函数成功,返回信号量集的标识;失败返回-1,错误原因存于error中。 该函数用来控制信号量(常用于设置信号量的初始值和销毁信号量),它的原型如下: int semctl(int semid, int sem_num, int command, ...); 1)参数semid是由semget函数返回的信号量标识。 2)参数sem_num是信号量集数组上的下标,表示某一个信号量,填0。 3)参数cmd是对信号量操作的命令种类,常用的有以下两个: IPC_RMID:销毁信号量,不需要第四个参数; SETVAL:初始化信号量的值(信号量成功创建后,需要设置初始值),这个值由第四个参数决定。第四参数是一个自定义的共同体,如下: // 用于信号灯操作的共同体。 union semun { int val; struct semid_ds *buf; unsigned short *arry; }; 4)如果semctl函数调用失败返回-1;如果成功,返回值比较复杂,暂时不关心它。 该函数有两个功能:1)等待信号量的值变为1,如果等待成功,立即把信号量的值置为0,这个过程也称之为等待锁;2)把信号量的值置为1,这个过程也称之为释放锁。 int semop(int semid, struct sembuf *sops, unsigned nsops); 1)参数semid是由semget函数返回的信号量标识。 2)参数nsops是操作信号量的个数,即sops结构变量的个数,设置它的为1(只对一个信号量的操作)。 3)参数sops是一个结构体,如下: struct sembuf { short sem_num; // 信号量集的个数,单个信号量设置为0。 short sem_op; // 信号量在本次操作中需要改变的数据:-1-等待操作;1-发送操作。 short sem_flg; // 把此标志设置为SEM_UNDO,操作系统将跟踪这个信号量。 // 如果当前进程退出时没有释放信号量,操作系统将释放信号量,避免资源被死锁。 }; 示例: 1)等待信号量的值变为1,如果等待成功,立即把信号量的值置为0; struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = -1; sem_b.sem_flg = SEM_UNDO; semop(sem_id, &sem_b, 1); 2)把信号量的值置为1。 struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = 1; sem_b.sem_flg = SEM_UNDO; semop(sem_id, &sem_b, 1);
例子如下,
/* * 程序名:book259.cpp,此程序用于演示信号量的使用方法。 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/ipc.h> #include <sys/sem.h> class CSEM { private: union semun // 用于信号灯操作的共同体。 { int val; struct semid_ds *buf; unsigned short *arry; }; int sem_id; // 信号灯描述符。 public: bool init(key_t key); // 如果信号灯已存在,获取信号灯;如果信号灯不存在,则创建信号灯并初始化。 bool wait(); // 等待信号灯挂出。 bool post(); // 挂出信号灯。 bool destroy(); // 销毁信号灯。 }; int main(int argc, char *argv[]) { CSEM sem; // 初始信号灯。 if (sem.init(0x5000)==false) { printf("sem.init failed.\n"); return -1; } printf("sem.init ok\n"); // 等待信信号挂出,等待成功后,将持有锁。 if (sem.wait()==false) { printf("sem.wait failed.\n"); return -1; } printf("sem.wait ok\n"); sleep(50); // 在sleep的过程中,运行其它的book259程序将等待锁。 // 挂出信号灯,释放锁。 if (sem.post()==false) { printf("sem.post failed.\n"); return -1; } printf("sem.post ok\n"); // 销毁信号灯。 // if (sem.destroy()==false) { printf("sem.destroy failed.\n"); return -1; } // printf("sem.destroy ok\n"); } bool CSEM::init(key_t key) { // 获取信号灯。 if ( (sem_id=semget(key,1,0640)) == -1) { // 如果信号灯不存在,创建它。 if (errno==2) { if ( (sem_id=semget(key,1,0640|IPC_CREAT)) == -1) { perror("init 1 semget()"); return false; } // 信号灯创建成功后,还需要把它初始化成可用的状态。 union semun sem_union; sem_union.val = 1; if (semctl(sem_id,0,SETVAL,sem_union) < 0) { perror("init semctl()"); return false; } } else { perror("init 2 semget()"); return false; } } return true; } bool CSEM::destroy() { if (semctl(sem_id,0,IPC_RMID) == -1) { perror("destroy semctl()"); return false; } return true; } bool CSEM::wait() { struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = -1; sem_b.sem_flg = SEM_UNDO; if (semop(sem_id, &sem_b, 1) == -1) { perror("wait semop()"); return false; } return true; } bool CSEM::post() { struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = 1; sem_b.sem_flg = SEM_UNDO; if (semop(sem_id, &sem_b, 1) == -1) { perror("post semop()"); return false; } return true; }
结果如下,
可以看出,在两个程序运行时候,一个程序占用信号资源,在 init 以后,处于 wait 状态;另外一个运行以后,在 init 以后,处于挂起状态,在第一个程序 post 以后,第二个程序才处于 wait 状态。这里 init 是初始化以后,wait 是等待,但是占用了现在的信号资源,post 是挂出,释放了信号资源。
可以用
ipcs -s // 查看系统的信号量 ipcrm sem 8 // 手工删除信号量
结果如下,
和多进程相比,多线程是一种比较节省资源的多任务操作方式。 启动一个新的进程必须分配给它独立的地址空间,每个进程都有自己的堆栈段和数据段,系统开销比较高,进行数据的传递只能通过进行间通信的方式进行。 在同一个进程中,可以运行多个线程,运行于同一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享全局变量和对象,启动一个线程所消耗的资源比启动一个进程所消耗的资源要少。
在Linux下,采用pthread_create函数来创建一个新的线程,函数声明:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg); 参数thread为为指向线程标识符的地址。 参数attr用于设置线程属性,一般为空,表示使用默认属性。 参数start_routine是线程运行函数的地址,填函数名就可以了。 参数arg是线程运行函数的参数。新创建的线程从start_routine函数的地址开始运行,该函数只有一个无类型指针参数arg。若要想向start_routine传递多个参数,可以将多个参数放在一个结构体中,然后把结构体的地址作为arg参数传入,但是要非常慎重,程序员一般不会这么做。 在编译时注意加上-lpthread参数,以调用静态链接库。因为pthread并非Linux系统的默认库。
如果进程中的任一线程调用了exit,则整个进程会终止,所以,在线程的start_routine函数中,不能采用exit。线程的终止有三种方式:1)线程的start_routine函数代码结束,自然消亡。2)线程的start_routine函数调用pthread_exit结束。3)被主进程或其它线程中止。pthread_exit函数的声明如下:
void pthread_exit(void *retval); 参数retval填空,即0
有一个例子,多线程的socket服务端,注意需要添加 socket 客户端程序实现通信,程序如下,
/* * 程序名:book261.cpp,此程序用于演示多线程的socket通信服务端 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <netdb.h> #include <stdlib.h> #include <signal.h> #include <pthread.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> class CTcpServer { public: int m_listenfd; // 服务端用于监听的socket int m_clientfd; // 客户端连上来的socket CTcpServer(); bool InitServer(int port); // 初始化服务端 bool Accept(); // 等待客户端的连接 // 向对端发送报文 int Send(const void *buf,const int buflen); // 接收对端的报文 int Recv(void *buf,const int buflen); // void CloseClient(); // 关闭客户端的socket,多线程服务端不需要这个函数。 // void CloseListen(); // 关闭用于监听的socket,多线程服务端不需要这个函数。 ~CTcpServer(); }; CTcpServer TcpServer; // SIGINT和SIGTERM的处理函数 void EXIT(int sig) { printf("程序退出,信号值=%d\n",sig); close(TcpServer.m_listenfd); // 手动关闭m_listenfd,释放资源 exit(0); } // 与客户端通信线程的主函数 void *pth_main(void *arg); int main() { // 忽略全部的信号 for (int ii=0;ii<50;ii++) signal(ii,SIG_IGN); // 设置SIGINT和SIGTERM的处理函数 signal(SIGINT,EXIT); signal(SIGTERM,EXIT); if (TcpServer.InitServer(5005)==false) { printf("服务端初始化失败,程序退出。\n"); return -1; } while (1) { if (TcpServer.Accept() == false) continue; pthread_t pthid; // 创建一线程,与新连接上来的客户端通信 if (pthread_create(&pthid,NULL,pth_main,(void*)((long)TcpServer.m_clientfd))!=0) { printf("创建线程失败,程序退出。n"); return -1; } printf("与客户端通信的线程已创建。\n"); } } CTcpServer::CTcpServer() { // 构造函数初始化socket m_listenfd=m_clientfd=0; } CTcpServer::~CTcpServer() { if (m_listenfd!=0) close(m_listenfd); // 析构函数关闭socket if (m_clientfd!=0) close(m_clientfd); // 析构函数关闭socket } // 初始化服务端的socket,port为通信端口 bool CTcpServer::InitServer(int port) { if (m_listenfd!=0) { close(m_listenfd); m_listenfd=0; } m_listenfd = socket(AF_INET,SOCK_STREAM,0); // 创建服务端的socket // 把服务端用于通信的地址和端口绑定到socket上 struct sockaddr_in servaddr; // 服务端地址信息的数据结构 memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 本主机的任意ip地址 servaddr.sin_port = htons(port); // 绑定通信端口 if (bind(m_listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } // 把socket设置为监听模式 if (listen(m_listenfd,5) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } return true; } bool CTcpServer::Accept() { if ( (m_clientfd=accept(m_listenfd,0,0)) <= 0) return false; return true; } int CTcpServer::Send(const void *buf,const int buflen) { return send(m_clientfd,buf,buflen,0); } int CTcpServer::Recv(void *buf,const int buflen) { return recv(m_clientfd,buf,buflen,0); } // 与客户端通信线程的主函数 void *pth_main(void *arg) { int clientfd=(long) arg; // arg参数为新客户端的socket。 // 与客户端通信,接收客户端发过来的报文后,回复ok。 char strbuffer[1024]; while (1) { memset(strbuffer,0,sizeof(strbuffer)); if (recv(clientfd,strbuffer,sizeof(strbuffer),0)<=0) break; printf("接收:%s\n",strbuffer); strcpy(strbuffer,"ok"); if (send(clientfd,strbuffer,strlen(strbuffer),0)<=0) break; printf("发送:%s\n",strbuffer); } printf("客户端已断开连接。\n"); close(clientfd); // 关闭客户端的连接。 pthread_exit(0); }
文件命名为 Thread.cpp ,并把 client.cpp 拷在文件下,其中 makefile 文件如下,
all:client Thread client:client.cpp g++ -g -o client client.cpp Thread:Thread.cpp g++ -g -o Thread Thread.cpp -lpthread
运行结果如下,
上面程序注意几个点,
1. 对信号的处理,设置处理为 Ctrl+c,kill 信号量时,使用 EXIT 函数: // 设置SIGINT和SIGTERM的处理函数 signal(SIGINT,EXIT); signal(SIGTERM,EXIT); 2. 注意在 pth_main 中作为线程执行的函数,语法在上面提到过,但是注意参数的类型转 换。
线程有joinable和unjoinable两种状态,如果线程是joinable状态,当线程主函数终止时(自己退出或调用pthread_exit退出)不会释放线程所占用内存资源和其它资源,这种线程被称为“僵尸线程”。创建线程时默认是非分离的,或者称为可连接的(joinable)。避免僵尸线程就是如何正确的回收线程资源,有四种方法:
方法一:创建线程后,在创建线程的程序中调用pthread_join等待线程退出,一般不会采用这种方法,因为pthread_join会发生阻塞。 pthread_join(pthid,NULL); 2)方法二:创建线程前,调用pthread_attr_setdetachstate将线程设为detached,这样线程退出时,系统自动回收线程资源。 pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED); // 设置线程的属性。 pthread_create(&pthid,&attr,pth_main,(void*)((long)TcpServer.m_clientfd); 3)方法三:创建线程后,在创建线程的程序中调用pthread_detach将新创建的线程设置为detached状态。 pthread_detach(pthid); 4)方法四:在线程主函数中调用pthread_detach改变自己的状态。 pthread_detach(pthread_self());
锁大概有两种:一种是不允许访问;另一种是资源忙,同一时间只允许一个使用者占用,其它使用者必须要等待。 对多线程来说,资源是共享的,基本上不存在不允许访问的情况,但是,共享的资源在某一时间点只能有一个线程占用,所以需要给资源加锁。
线程的锁的种类有互斥锁、读写锁、条件变量、自旋锁、信号灯。学习中,只介绍互斥锁,其它的锁应用场景复杂,开发难度很大,不合适初学者。
互斥锁机制是同一时刻只允许一个线程占有共享的资源。
1. 初始化锁
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutex_attr_t *mutexattr); 其中参数 mutexattr 用于指定锁的属性(见下),如果为NULL则使用缺省属性。 互斥锁的属性在创建锁的时候指定,当资源被某线程锁住的时候,其它的线程在试图加锁时表现将不同。当前有四个值可供选择: 1)PTHREAD_MUTEX_TIMED_NP,这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将形成一个等待队列,并在解锁后按优先级获得锁。这种锁策略保证了资源分配的公平性。 2)PTHREAD_MUTEX_RECURSIVE_NP,嵌套锁,允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。 3)PTHREAD_MUTEX_ERRORCHECK_NP,检错锁,如果同一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。 4)PTHREAD_MUTEX_ADAPTIVE_NP,适应锁,动作最简单的锁类型,等待解锁后重新竞争。
2、阻塞加锁
int pthread_mutex_lock(pthread_mutex *mutex); 如果是锁是空闲状态,本线程将获得这个锁;如果锁已经被占据,本线程将排队等待,直到成功的获取锁。
3、非阻塞加锁
int pthread_mutex_trylock( pthread_mutex_t *mutex); 该函数语义与 pthread_mutex_lock() 类似,不同的是在锁已经被占据时立即返回 EBUSY,不是挂起等待。
4、解锁
int pthread_mutex_unlock(pthread_mutex *mutex); 线程把自己持有的锁释放
5. 销毁锁(此时锁必需unlock状态,否则返回EBUSY)
int pthread_mutex_destroy(pthread_mutex *mutex); 销毁锁之前,锁必需是空闲状态(unlock)。
多线程可以共享资源(变量和对象),对编程带来了方便,但是某些对象虽然可以共享,但在同一个时间只能由一个线程使用,多个线程同时使用会产生冲突,例如 socket 连接,数据库连接池。源程序如下,因为作者没有把 CTcpserver 类加入,我后来加入了运行出来,
/* * 程序名:book263.cpp,此程序用于演示多线程的互斥锁 * 作者:C语言技术网(www.freecplus.net) 日期:20190525 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <pthread.h> class CTcpServer { public: int m_listenfd; // 服务端用于监听的socket int m_clientfd; // 客户端连上来的socket CTcpServer(); bool InitServer(int port); // 初始化服务端 bool Accept(); // 等待客户端的连接 // 向对端发送报文 int Send(const void *buf,const int buflen); // 接收对端的报文 int Recv(void *buf,const int buflen); // void CloseClient(); // 关闭客户端的socket,多线程服务端不需要这个函数。 // void CloseListen(); // 关闭用于监听的socket,多线程服务端不需要这个函数。 ~CTcpServer(); }; CTcpServer TcpServer; //xx pthread_mutex_t mutex; // 申明一个互斥锁 // 与客户端通信线程的主函数 void *pth_main(void *arg) { int pno=(long)arg; // 线程编号 pthread_detach(pthread_self()); char strbuffer[1024]; for (int ii=0;ii<3;ii++) // 与服务端进行3次交互。 { //xx pthread_mutex_lock(&mutex); // 加锁 memset(strbuffer,0,sizeof(strbuffer)); sprintf(strbuffer,"线程%d:这是第%d个超级女生,编号%03d。",pno,ii+1,ii+1); if (TcpClient.Send(strbuffer,strlen(strbuffer))<=0) break; printf("发送:%s\n",strbuffer); memset(strbuffer,0,sizeof(strbuffer)); if (TcpClient.Recv(strbuffer,sizeof(strbuffer))<=0) break; printf("线程%d接收:%s\n",pno,strbuffer); //xx pthread_mutex_unlock(&mutex); // 释放锁 // usleep(100); // usleep(100),否则其它的线程无法获得锁。 } pthread_exit(0); } int main() { // 向服务器发起连接请求 if (TcpClient.ConnectToServer("172.16.0.15",5051)==false) { printf("TcpClient.ConnectToServer(\"172.16.0.15\",5051) failed,exit...\n"); return -1; } //xx pthread_mutex_init(&mutex,0); // 创建锁 pthread_t pthid1,pthid2; pthread_create(&pthid1,NULL,pth_main,(void*)1); // 创建第一个线程 pthread_create(&pthid2,NULL,pth_main,(void*)2); // 创建第二个线程 pthread_join(pthid1,NULL); // 等待线程1退出。 pthread_join(pthid2,NULL); // 等待线程2退出。 //xx pthread_mutex_lock(&mutex); // 销毁锁 } CTcpServer::CTcpServer() { // 构造函数初始化socket m_listenfd=m_clientfd=0; } CTcpServer::~CTcpServer() { if (m_listenfd!=0) close(m_listenfd); // 析构函数关闭socket if (m_clientfd!=0) close(m_clientfd); // 析构函数关闭socket } // 初始化服务端的socket,port为通信端口 bool CTcpServer::InitServer(int port) { if (m_listenfd!=0) { close(m_listenfd); m_listenfd=0; } m_listenfd = socket(AF_INET,SOCK_STREAM,0); // 创建服务端的socket // 把服务端用于通信的地址和端口绑定到socket上 struct sockaddr_in servaddr; // 服务端地址信息的数据结构 memset(&servaddr,0,sizeof(servaddr)); servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 本主机的任意ip地址 servaddr.sin_port = htons(port); // 绑定通信端口 if (bind(m_listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } // 把socket设置为监听模式 if (listen(m_listenfd,5) != 0 ) { close(m_listenfd); m_listenfd=0; return false; } return true; } bool CTcpServer::Accept() { if ( (m_clientfd=accept(m_listenfd,0,0)) <= 0) return false; return true; } int CTcpServer::Send(const void *buf,const int buflen) { return send(m_clientfd,buf,buflen,0); } int CTcpServer::Recv(void *buf,const int buflen) { return recv(m_clientfd,buf,buflen,0); }
运行结果如下,
看出,如果客户端接收的数据,是存在乱序的,没有规律,因此给她们加上锁,注意,上面注释的部分取消就行,是这几个语句,
..... 全局变量 pthread_mutex_t mutex; // 申明一个互斥锁 .....线程函数 pthread_mutex_lock(&mutex); // 加锁 .....线程函数 pthread_mutex_unlock(&mutex); // 释放锁 usleep(100); // usleep(100),否则其它的线程无法获得锁。 .....主函数 pthread_mutex_init(&mutex,0); // 创建锁 ..... pthread_mutex_lock(&mutex); // 销毁锁
运行结果如下,
可以看出现在接收有序了,互斥锁产生了作用。
后面对网络编程知识继续深入学习,并进行复现,修改总结。