学习计算机网络编程

一、思路和学习方法

  本文学习于: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) 知识。
  在学习项目过程中,仔细对了里面源码进行学习,对基础知识不理解,不系统,一点一点的查阅,效率相当慢,而且不能理解整个架构。因此,静下心来把这个课程过一遍,实践中学习。

二、网络编程基本结构了解

2.1 网络编程基本概念

  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 serverclient:client.cppg++ -g -o client client.cppserver:server.cppg++ -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.cppchar 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 // 退出其他都在尝试中练习

  通过对上面的学习,对网络编程有了一定的了解,上面基本都是固定格式,要记住他们的通信规律和方法,按照模板修改就可以,后面继续学习。

2.2 以职业程序员角度介绍网络编程

2.2.1 socket()函数详解

  再次声明,我仅仅是用作学习记录,再分享我的学习过程,如果侵权我立马撤回。函数声明如下

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

  如下

2.2.2 主机字节序与网络字节序

  字节顺序是指占内存多于一个字节类型的数据在内存中的存放顺序,一个 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 longTCP 协议中的主机地址和端口采用整数来表示:
192.168.190.134
// 小端方式
11000000 10101000 10111110 10000110
3232284294 ----> 十进制数存放// 大端方式 --- 网络字节序方式
10000110 10111110 10101000 11000000
2260641984 ----> 十进制数存放
2.2.3 网络通信的结构体

  网络编程中,网络协议,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)还是 IPv6int h_length;        // 主机的 IP 地址长度,IPv4 地址为 4 , IPv6地址为 16char ** 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 地址转化为网络字节序。
2.2.4 bind() 函数

  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函数一定会报错,否则一般不会返回错误。
2.2.5 listen(), connect() 和 accept() 函数

  对几个函数的总结

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。
2.2.6 TCP 的三次握手与连接队列

  三次握手图解如下,

其中 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 数据发送和接收。

2.2.7 send() 和 recv() 函数

  现在介绍两个函数,发送数据和接受数据函数,其函数声明如下。
  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 足够的时候,继续发送。

三、TCP相关知识

3.1 TCP 报文分包和粘包

  基本概念

分包:发送方发送字符串“helloworld”,接收方却收到两个字符串“hello”和“world”。
粘包:发送方发送两个字符串“hello”+“world”,接收方却一次性收到了“helloworld”。但是 TCP 传输可以保证几点:
1. 顺序不变;
2. 分割的包中间不会插入其他数据。为解决分包和粘包问题,定义一份协议,常用方式为
报文长度 + 报文内容   0010helloworld
报文长度 ascii 码,二进制整数

  关于 TCP 报文分包和粘包的情况,视频中有演示,我自己按照它的方法,在我的虚拟机上面运行,并没有出现杂乱的情况。不过 UP 主说后面会继续讲解,后面再继续理解。

3.2 职业程序员编程方法

3.2.1 基础未封装的 socket 程序

  socket 编程的函数很多,细节也很多,如果每个项目都从 socket 的函数开始编程,代码会非常繁琐。解决方法就是封装(造轮子)。

3.2.2 Writen() 函数和 Readn() 函数

  recv() 函数可能存在读取的报文不完整的情况,send() 也可能存在写入数据不完整的情况。因此写了两个函数了解这些问题,两个函数如下,

3.2.3 TcpWrite() 和 TcpRead() 函数

  为了解决 TCP 分包和粘包的问题,用 TcpWrite 和 TcpRead 两个函数来解决问题

3.2.4 把 socket 服务端封装成 CTcpServer 类

  其源码如下,

/** 程序名: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;   // 服务端用于监听的socketint m_clientfd;   // 客户端连上来的socketCTcpServer();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()
{// 构造函数初始化socketm_listenfd=m_clientfd=0;
}CTcpServer::~CTcpServer()
{if (m_listenfd!=0) close(m_listenfd);  // 析构函数关闭socketif (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_INETservaddr.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);
}

3.2.5 把 socket 客户端封装成 CTcpClient 类

  其源码如下,

/** 程序名: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); // 创建客户端的socketstruct 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,释放资源)。

三、多进程和多线程知识

3.1 进程的基本函数

  关于进程的几个重要函数,

// 1. getpid库函数的功能是获取本程序运行时进程的编号。
pid_t getpid();// 2. fork函数用于产生一个新的进程,函数返回值pid_t是一个整数,在父进程中,返回值是子进程编号,在子进程中,返回值是0。
pid_t fork();linux 对进程的相关操作:
ps                     查看当前终端的进程。
ps -ef |grep book      查看系统全部的进程

   子进程和父进程使用相同的代码段;子进程拷贝了父进程的堆栈段和数据段。子进程一旦开始运行,它复制了父进程的一切数据,然后各自运行,相互之间没有影响。 在父进程中定义的变量子进程中会复制一个副本,fork之后,子进程对变量的操作不会影响父进程,父进程对变量的操作也不会影响子进程。还需要注意,

1)进程的编号是系统动态分配的,相同的程序在不同的时间执行,进程的编号是不同的。
2)进程的编号会循环使用,但是,在同一时间,进程的编号是唯一的,也就是说,不管任何时间,系统
不可能存在两个编号相同的进程。

3.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等。

3.3 Linux 信号

   如果想让程序在后台运行,执行程序的时候,命令的最后面加“&”符号。程的数

比如之前运行客户端使用:
./client 127.0.0.1 5005 &
这样可以运行多个客户端查看进程
ps -ef|grep clientkillall 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 中止与杀程序是相同的效果。信号的作用: 如果能向后台程序发送一个信号,后台程序收到这个信号后,调用一个函数,在函数中编写释放资源的代码,程序就可以有计划的退出, 安全而体面。

3.4 共享内存

   共享内存(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) // 手动删除共享内存

运行如下,

3.5 Linux 信号量

   信号量(信号灯)本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。 它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、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    //  手工删除信号量

   结果如下,

3.6 Linux 线程

   和多进程相比,多线程是一种比较节省资源的多任务操作方式。 启动一个新的进程必须分配给它独立的地址空间,每个进程都有自己的堆栈段和数据段,系统开销比较高,进行数据的传递只能通过进行间通信的方式进行。 在同一个进程中,可以运行多个线程,运行于同一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享全局变量和对象,启动一个线程所消耗的资源比启动一个进程所消耗的资源要少
   在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;   // 服务端用于监听的socketint m_clientfd;   // 客户端连上来的socketCTcpServer();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()
{// 构造函数初始化socketm_listenfd=m_clientfd=0;
}CTcpServer::~CTcpServer()
{if (m_listenfd!=0) close(m_listenfd);  // 析构函数关闭socketif (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_INETservaddr.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.cppg++ -g -o client client.cppThread:Thread.cppg++ -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());

3.7 Linux 线程同步

   锁大概有两种:一种是不允许访问;另一种是资源忙,同一时间只允许一个使用者占用,其它使用者必须要等待。 对多线程来说,资源是共享的,基本上不存在不允许访问的情况,但是,共享的资源在某一时间点只能有一个线程占用,所以需要给资源加锁。
   线程的锁的种类有互斥锁、读写锁、条件变量、自旋锁、信号灯。学习中,只介绍互斥锁,其它的锁应用场景复杂,开发难度很大,不合适初学者。
   互斥锁机制是同一时刻只允许一个线程占有共享的资源
   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;   // 服务端用于监听的socketint m_clientfd;   // 客户端连上来的socketCTcpServer();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()
{// 构造函数初始化socketm_listenfd=m_clientfd=0;
}CTcpServer::~CTcpServer()
{if (m_listenfd!=0) close(m_listenfd);  // 析构函数关闭socketif (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_INETservaddr.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);   // 销毁锁

运行结果如下,

可以看出现在接收有序了,互斥锁产生了作用。

四、总结

  后面对网络编程知识继续深入学习,并进行复现,修改总结。

学习C++项目—— 计算机网络编程基础 和 学习多线程,多进程基础相关推荐

  1. linux编程学习_您需要编程技能才能学习Linux吗?

    linux编程学习 几个月前,我参加了edX提供的Linux入门课程. 这是一门18章的课程,其中包含大量阅读材料,一些视频以及随意测试知识水平的课程. 我写了关于前六章的内容,以及该课程的工作原理, ...

  2. 线性代数学习指导与MATLAB编程实践,线性代数学习指导与MATLAB编程实践

    第一章行列式1 第一节内容提要1 第二节典型例题4 第三节编程应用15 第四节习题21 习题答案与解法提示24 第二章矩阵28 第一节内容提要28 第二节典型例题33 第三节编程应用46 第四节习题5 ...

  3. Modern C++ 学习笔记——C++函数式编程

    往期精彩: Modern C++ 学习笔记--易用性改进篇 Modern C++ 学习笔记 -- 右值.移动篇 Modern C++ 学习笔记 -- 智能指针篇 Modern C++ 学习笔记 -- ...

  4. Python学习之面向对象高级编程

    Python学习目录 1. 在Mac下使用Python3 2. Python学习之数据类型 3. Python学习之函数 4. Python学习之高级特性 5. Python学习之函数式编程 6. P ...

  5. linux环境编程 学习,学习linux环境高级编程首先学习的是文件的操作。因为有.pdf...

    学习linux环境高级编程首先学习的是文件的操作.因为有 学习 Linux 环境高级编程,首先学习的是文件的操作.因为有一句很有趣的话"Linux 下一切皆文件".所以掌握了文件操 ...

  6. 九章云极发布YLearn因果学习开源项目;字节跳动大量招聘芯片工程师;苹果被俄法院罚款约21.6万元 | 每日大事件...

    数据智能产业创新服务媒体 --聚焦数智 · 改变商业 01.九章云极DataCanvas公司开源发布YLearn因果学习开源项目 7月12日,九章云极DataCanvas公司发布YLearn因果学习开 ...

  7. 《爬虫与网络编程基础》学习

    <爬虫与网络编程基础>学习 任务1:计算机网络基础 Step1-2 Step 3:关于XML格式的学习 XML文件格式 XML文件的常见操作 1.构建xml格式文件 2. 保存XML文件 ...

  8. Python学习入门3:编程零基础应当如何开始学习Python?

    零基础学编程,用python入门是个不错的选择,虽然国内基本上还是以c语言作为入门开发语言,但在国外,已经有比较多的学校使用python作为入门编程语言. 具体怎么选择你的第一门编程语言可以看下图: ...

  9. java webpack web项目_零基础如何学习web前端,入门教程分享

    前端作为互联网时代直接触达用户的窗口,大到我们每天浏览到的网站,小到一次点击按钮的页面,前端无处不在.并且在产品的众多开发环节之中,最能让用户直观感受到的就是前端开发.因而前端行业的广阔发展前景也吸引 ...

最新文章

  1. 大学计算机二级培训新闻稿,计算机二级模拟考通讯稿
  2. 基于动态IP的Internet视频监控解决方案(作者:吴晓晖)
  3. jQuery和Vue的区别(转载)
  4. win8.1 linux系统,电脑显示win8.1linux系统失效的原因及解决办法!
  5. 操作系统课设之简单 shell 命令行解释器的设计与实现
  6. 诺基亚9.3再曝光:后置1亿像素圆形五摄 价格或超6000元
  7. 暑假第二周总结(2018.7.16——7.22)
  8. python文件读取方法
  9. 不使用border-radius,实现一个可复用的高度和宽度都自适应的圆角矩形
  10. 为什么要在2021年放弃Jenkins?我已经对他失去耐心了...
  11. 几种在Linux下查询外网IP的办法(转)
  12. WPS2000系列之二样式管理(转)
  13. zz 联想ThinkPad X230换固态盘小记
  14. HTTP 协议中的长连接和短连接
  15. AS+图灵机器人官网+HTTP POST(json)+JsonReader实现安卓课设《智能聊天机器人》填坑记录
  16. 用命令从FTP服务器下载文件
  17. 【坐标轴移位+主辅刻度设定】两年梳理一次性清晰的展示出来,大图解析学习超简单
  18. Unity3d 实现节奏空间(Beat Saber)模型切割功能项目工程源码。
  19. Error: spawn cmd ENOENT at Process.ChildProcess._handle.onexit (internal/child_process.js:190:19
  20. 员工排班优化 员工排班 java 排班

热门文章

  1. pytorch 实现RBF网络
  2. 编程哲学之C#篇:02——学习思维
  3. 使用NSOperation实现异步下载
  4. 网络校时(非NTP)
  5. COOKIE和Session的原理及异同
  6. extjs6入门:用sencha cmd搭建简单的extjs6项目
  7. 比特币网站Flexcoin遭黑客攻击 损失极大 被迫关闭
  8. ArcEngine10.1二次开发错误: 无法嵌入互操作类型,请改用适用的接口
  9. StringBuilder与StringBuffer的区别(转)
  10. 使用cronolog自动分割apache的日志。