目录
一、理解网络编程和套接字
二、套接字类型与协议设置
三、基于TCP的服务器端/客户端
一、理解网络编程和套接字
1.网络编程中接受链接请求的套接字创建过程:
- 第一步:调用socket函数创建套接字
- 第二步:调用bind函数分配IP地址和端口号
- 第三步:listen函数转为可接受请求状态
- 第四步:调用accept函数受理接受请求
2.网络编程中发出链接请求的套接字创建过程:
- 第一步:调用socket函数创建套接字
- 第二步:调用connect函数匹配IP地址和端口号并发送链接请求
3.文件描述符(Windows称文件句柄)可以看作是一个编号别名
- 0、1、2是为标准I/O保留的描述符
- 打开的文件按时间顺序分别赋予3、4、5……
4.C语言在Linux下打开文件
1 | #include <sys/types.h> |
常用文件打开模式
1 | O_CREAT-文件不存在时创建文件 |
5.C语言在Linux下关闭文件
1 | #include <unistd.h> |
6.C语言在Linux下将数据写入文件
1 | #include <unistd.h> |
7.C语言在Linux下读取文件数据
1 | #include <unistd.h> |
8.Windows平台上设置头文件和库
- 链接外部库,在CMakeLists.txt里加上以下语句链接ws2_32,注意写要在add_executable之上
1
link_libraries(ws2_32)
- 头文件
1
2
3#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
9.socket代码在window与linux的差异
- 开头要多写以下两行来初始化套接字库
1
2
3WSADATA wsaData;
WSAStartup(MAKEWORD(2,2), &wsaData); - sokcet()返回的是SOCKET对象而不是int
- struct sockaddr_in都要改成SOCKADDR_IN
- 同理struct sockaddr*也都要改成SOCKADDR*
- socklen_t要改成int
- write()要换成send(),且最后要多加一个参数0,其余参数保持不变
- read()要换成recv(),且最后要多加一个参数0,其余参数保持不变
- close()改成closesocket()
- 结尾加一行注销套接字库
1
WSACleanup()
10.套接字在网络编程中的作用是什么?为何称它为套接字
答:套接字是网络数据传输用的软件,是用来连接网络的工具。在网络连接中套接字就像一个插口,程序插上去后就可以与连接另一方进行对话。
二、套接字类型与协议设置
1.socket的第一个参数是协议簇。理论上建立socket时是指定协议,应该用PF_xxxx,设置地址时应该用AF_xxxx,但混用也没关系因为大部分时候它俩相等。一般常用的只是PF_INET
2.socket的第二个参数是套接字类型。
- SOCK_STREAM面向连接的套接字(形如传送带)
- 特点:传输过程数据不会消失、按序传输数据、基于字节的、传输的数据不存在数据边界(即套接字内部有缓冲区,可能多次write的数据,一次read就能接受完全)
- 缓存满的时候也不会丢失数据,因为此时传输端套接字会自动停止传输,即面向连接的套接字会根据接收端的状态来传数据,甚至传错还会重传
- 套接字连接必须一一对应,面向连接的套接字只能与另一个同样特性的套接字连接
- SOCK_DGRAM面向消息的套接字(形如高速移动的快递员)
- 特点:强调快速传输而非传输顺序、传输的数据可能丢失或损毁、限制每次传输的数据大小、传输的数据有数据边界(分两次发送的,就必须分两次读取)
3.socket第三个参数是协议的最终选择。大部分时候可以传0,因为根据前两个参数基本能确定
- PF_INET+SOCK_STREAM,一定是IPPROTO_TCP
- PF_INET+SOCK_DGRAM,一定是IPPROTO_UDP
4.windows下的sokcet()返回其实也是整型,也可以用int接受,但为了保存套接字句柄还是用SOCKET接受比较好,发生错误时会返回INVALD_SOCKET(用整型接受就是-1)
5.如何实现等待一定时间后再操作?(这种让CPU执行多多余任务以延迟代码运行的方式称为”Busy Waiting”)
1 | for(int i = 0; i < 3000; i++){ |
地址族与数据序列
1.IPv4和IPv6的差别主要是表示IP地址所用的字节数,目前通用的还是IPv4(IPv6是为了应对IP地址耗尽问题提出的标准,还未普及)
- IPv4(Internet Protocol version 4)4字节地址族
- IPv6(Internet Protocol version 6)16字节地址族
2.给某公司发送数据步骤
- 先把数据传给该公司4字节IP地址的网络地址(根据A、B、C、D类来判断前几段是网络地址)
- 该公司的路由器再根据后几段的主机地址把数据传给目标计算机
3.路由器和交换机都可以完成外网与本地主机之间的数据交换,实际用途差别不大
4.网络类型特殊记法:
- A类地址以0-127打头
- B类地址以128-191打头
- C类地址以192-223打头
5.网络接口卡NIC(即网卡)是一种数据传输设备,多台计算机发送来的数据(包含IP地址和端口号)会经过NIC然后再由系统根据端口号分配给不同套接字
- 自定义的端口号要在1024-65535之间
- 同类型套接字端口号不能重复,即若一个TCP套接字用了1234端口,则其他TCP套接字就不能使用1234端口了,但是可以有另一个UDP套接字使用1234端口
6.结构体sockaddr_in的成员分析
- sin_family表示选择的协议使用的地址族,可以的值为:
- AF_INET:IPv4协议中使用的地址族
- AF_INET6:IPv6协议中使用的地址族
- AF_LOCAL:本地通信UNIX协议中使用的地址族
- sin_port保存16位端口号,以网络字节序保存
- sin_addr保存32位IP地址,以网络字节序保存
- 里面有个成员s_addr,所有要用.sin_addr.s_addr = xx的形式来赋值
- sin_zero无用,只是为保证sockaddr_in后面转成sockaddr结构一致而填充的0
7.sin_port端口号的赋值
- 为避免大端序往小端序传会颠倒问题,网络传输数据时约定统一的方式,这种约定称为网络字节序,即全部统一为大端序
- 一般只用htons()将主机字节序转换成网络字节序即可给端口赋值(注意参数是整数,不用””),长的就用htonhl()
- ntohs()和ntohl()则是反过来转换
8.sin_addr.s_addr网络ip地址的赋值
- 使用函数inet_addr(),注意参数是string要有””,失败的话会返回INADDR_NONE
- 也可以使用函数htonl(INADDR_ANY)来赋值,这种方式可以自动获得运行服务器端的计算机IP地址,一般在服务器端采用,客户端除非带有一定服务器功能否则不用
- 服务器端也需要确定网络地址的原因是:可能会碰上有多个NIC即一个计算机多种ip地址的情形
9.执行客户端client.cpp时要多传递一个IP地址(服务端地址),如果这个IP地址是计算机本身的地址的话就称为回送地址(即服务端和客户端在同一计算机上运行)。无论什么程序,一旦使用回送地址发送数据,协议软件立即返回之,不进行任何网络传输
10.为什么不直接用sockaddr而要用sockaddr_in再转成sockaddr
答:因为sockaddr中的成员sa_data要求包含IP地址和端口号,剩余部分填充0,但这个要求对赋值地址信息而言非常麻烦,所以先采用方便赋值的sockaddr_in先赋值,再转换
四、基于TCP的服务器端/客户端
1.TCP/IP协议栈一般分四层
- 应用层
- 传输层(TCP或UDP层)
- 网络层(IP层)
- 网络接口层(链路层)
2.listen()会创建服务器端套接字(相当于等候室接待人员)
- 第二个参数表示请求等待队列长度频繁接受请求的服务器至少应为15
3.accept()会产生用于数据I/O的套接字
- listen收到连接请求才调用accept()
- 返回值类型是文件描述符
4.connect()使用时会给客户端套接字分配IP和端口(服务器端会在使用bind()时分配)
- 只有服务端接受连接请求或发生异常情况而中断连接,connect()才会返回
5.在linux下客户端向服务端传整数实验
客户端传整数模板
1
2
3
4
5
6
7
8
9
10
11cin >> count;
char num[1024]; //用作缓存数组,1024是一个预估最大大小
num[0] = (char) count;
for(int i = 0; i < count; i++){
int _num; //临时变量,因为只能传char[]
cin >> _num;
num[i*4+1] = (char) _num; //i*4意味着每个整型占4字节地址,+1意味着之前的count只占用1字节地址
}
# 实际上num[0] = 'count的值',num[1] = '第一个数的值',num[2]、num[3]、num[4] = '0',num[5] = ‘第二个数的值’.......依次类推
# 注意实际上可能不是'2'这种形式而是int->char自己的编码服务端接受整数模板
1
2
3
4
5
6
7
8
9
10
11
12char temple[1024]
read(clnt_sock, &count, 1); //因为count只占一个字节,所以只接受长度1
cout << "count:" << count << endl;
int temp = 0;
for(int i = 0; i < count; i++){
read(clnt_sock, &temple[i*4], 4); //读数据,注意这里的数字可以大但不能小,小的话会读不全, 但太大的话顶多会第一次就读完
}
for(int i = 0; i < count; i++){
cout << (int)temple[i*4] << endl; //将之前用强转编码的数据解码成int
}
# 如果count==3的话,实际这边一共收到14字节数据,count 1字节 + 三个数字 12字节 + 多的一个0字节
6.TCP套接字中的I/O缓冲
- write时数据移到输出缓冲,然后通过TCP移到输入缓冲,调用read时从输入缓冲中读取数据
- I/O缓冲在每个TCP套接字中单独存在
- I/O缓冲在创建套接字时自动生成
- 关闭套接字会丢失输入和输出缓冲中的数据,因为输出缓冲会一直向输入缓冲传递遗留的数据
7.缓冲空间只有50字节,服务器却发送了100字节这种问题怎么解决?
答:TCP中有滑动窗口协议(自动根据接收端窗口大小来决定发送端窗口大小),规定不会发起超过输入缓冲大小的数据传输,因此TCP不会因为缓冲溢出而丢失数据
8.socket的阻塞模式
- 当使用 write()/send()时:
- 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
- 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
- 如果要写入的数据大于缓冲区的最大长度,那么将分批写入,即先写一部分,发送过去,再写一部分
- 直到所有数据被写入缓冲区 write()/send() 才能返回
- 当使用 read()/recv()时:
- 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来
- 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
- 直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
9.TCP内部工作报文
- 会传一个SEQ和ACK
- SEQ:xx表示当前传输的数据包序号为xx
- ACK:xx表示xx-1号数据包我已经收到,下一步请传输xx号数据包
- 一般而言ACK = 上一个接受成功SEQ的数据包序号 + 上一个接受成功的SEQ的数据包字节数 + 1
10.在linux下服务端向客户端传输文件实验(即实现FTP文件传输)
服务端模板
1
2
3
4
5
6
7
8
9
10
11
12#include <sys/types.h>
#include <sys/stat.h>
#include<fcntl.h>
...
int flag = open("data.txt", O_RDONLY);
char temple[1024];
int read_true = 1;
while(read_true != 0 && read_true != -1){
read_true = read(flag, &temple, 10); //这个10可以随意改
write(clnt_sock, temple, read_true); //这个字节数建议设为read_true即读到多少字节就写多少字节,免得写入未赋值的字节出现乱码
}
close(flag);客户端模板
1
2
3
4
5
6
7
8
9
10
11
12#include <sys/types.h>
#include <sys/stat.h>
#include<fcntl.h>
int flag = open("data_client.txt", O_CREAT|O_TRUNC|O_WRONLY, 0666);
char data[1024];
int read_true = 1;
while(read_true != 0 && read_true != -1){
read_true = read(sock, &data, sizeof(data)-1);
write(flag, data, sizeof(data));
}
close(flag);
五、基于UDP的服务器端/客户端
1.与TCP需要多个套接字不同,UDP只需要自己有一个套接字就可以与多个主机通信
- IP只负责让离开主机A的UDP数据包传到主机B上
- 而最终将已经传到主机B上法人数据交给哪个UDP套接字这个任务则是由UDP来完成
2.Linux下的UDP连接与TCP不同的函数(Windows下一模一样)
- SOCKET(PF_INET, SOCK_DRGAM, 0)
- 不需要listen()、accept()和connect()
- read()变成了recvfrom(server_sock,buff, sizeof(buff), (struct sockaddr*)&clnt_addr, &clnt_addr_sz),clnt_addr_sz是sizeof(clnt_addr),即融合了accept()部分参数
- 注意!recvfrom()中的clnt_addr和clnt_sz都只是定义了但没有赋值的变量!
- write()变成了sendto(sock, buff, sizeof(buff), (struct sockeaddr*)&serv_addr, sizeof(serv_addr)),即融合了connect()部分参数
- 调用三次sendto()的数据,一定要用三次recvfrom()来读
3.UDP传输过程分为
- 第一阶段:向UDP套接字注册目标IP和端口号
- 第二阶段:传输数据
- 第三阶段:删除UDP套接字中注册的目标地址信息
4.在反复对同一目标传数据的,采用已连接的UDP套接字会更高效
- 方法是调用connect(),使用参数和TCP一模一样,区别只在于之前定义的sock关键字是SOCKET_DGRAM
- 之后既可以用revfrom()、sendto()也可以按TCP那种方式用read()、write(),但本质上还是UDP传输
六、优雅地断开套接字连接
1.close()是将两个流完全断开
- 一个流:主机输出,客户端输入
- 另一个流:主机输入,客户端输出
2.用于半关闭的函数shutdown()
- 头文件#include<sys/socket.h>
- shutdown(sock, howto)
- howto的可选值
- SHUT_RD断开输入流,该套接字不能发送数据(windows是SD_RECIEIVE)
- SHUT_WR断开输出流,该套接字不能发送数据(windows是SD_SEND)
- SHUT_RDWR同时断开该套接字的输入输出流(windows是SD_BOTH)
七、域名及网络地址
1.非必须一般不会修改服务器域名,而是去修改IP地址,获取某域名指向的IP地址用
1 | ping www.naver.com |
2.获取当前计算机中注册的默认DNS服务器使用(Linux中还需进一步输入server),这个命令获取的地址也是路由器的管理地址,属于C类的保留ip地址
1 | nslookup |
3.DNS本质上是一种层次化管理的分布式数据库系统
4.利用域名获取IP地址的函数gethostbyname()和结构体hostent()
- 头文件#include <netdb.h>
- 定义示例
1
2
3
4
5
6
7
8
9
10#include <netdb.h>
struct honstent *host;
host = gethostbyname(hostname); //hostname就是域名字符串
string element1 = host->h_name; //会返回官方域名,一般代表主页
vector<string> element2 = host->h_aliases; //会返回除了官方域名以外,指向该ip的域名数组
string element3 = host->h_addrtypr; //IPv4返回AF_INET,IPv6返回AF_INET6
int element4 = host->h_length; //返回IP地址的字节长度,IPv4返回4,IPv6返回16
vector<string> element5 = host->h_addr_list; //返回该域名对应的IP地址数组
5.利用IP地址获取域名的函数gethostbyaddr()
- 头文件还是#include <netdb.h>
- 要用到sockaddr_in结构体来赋ip地址
- 定义示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14#include <netdb.h>
struct honstent *host;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_addr.s_addr = inet_addr(ip); //ip就是ip地址字符串
host = gethostbyname((char*)&addr.sin_addr, 4, AF_INET); //IPv6就是6和AF_INET6
# 结构体成员内容与gethostbyname()一模一样
string element1 = host->h_name; //会返回官方域名,一般代表主页
vector<string> element2 = host->h_aliases; //会返回除了官方域名以外,指向该ip的域名数组
string element3 = host->h_addrtypr; //IPv4返回AF_INET,IPv6返回AF_INET6
int element4 = host->h_length; //返回IP地址的字节长度,IPv4返回4,IPv6返回16
vector<string> element5 = host->h_addr_list; //返回该域名对应的IP地址数组
八、套接字的多种可选项
1.用于读取套接字可选项和更改可选项调用的函数
- getsockopt()成功返回0,失败返回-1
- setsockopt()成功返回0,失败返回-1
2.查询和更改I/O缓冲区大小
- 查询
1
2
3
4
5
6
7int snd_buf, rcv_buf;
socklen_t len;
getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
# 此时snd_buf和rcv_buf存的就是缓冲区大小 - 更改
1
2
3
4
5
6int snd_buf = 1024*3, rcv_buf = 1024*3;
setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, sizeof(snd_buf));
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, sizeof(rcv_buf));
# 注意缓冲实际并不会完全按我们设定的值来,因为缓冲的设置需要谨慎处理,这里只是向系统传递我们的要求
3.关闭socket程序方式(谁先发出的FIN信号,谁就要进入Time-wait状态)
- 从客户端CRTL+C关闭,相当于客户端调用close(),正常
- 客户端也有Time-wait状态,但一般不关注,因为客户端的端口每次都会动态随机分配
- 从服务器端CRTL+C关闭,再用同一端口号运行服务器端时会输出bind()步骤会出错,约三分钟后恢复正常
- 原因:在四次挥手中服务器端发送fin信息后,套接字会进入Time-wait状态,此时对应端口仍处于正在使用状态
4.更改SO_REUSEADDR可以分配处于Time-wait状态下的套接字端口号
1 | int option = 1; |
5.Nagle算法(只有收到上一个数据包的ack时才发送下一数据包,等待的时候将数据放入缓冲随着下一次一起发送)
- TCP套接字默认使用Nagle算法,可以提高网络传输效率(尽可能缓冲,减少数据包的数量)
- 传输大文件数据时应禁用Nagle,因为使不使用Nagle都会装满输出缓冲后再发送,而使用Nagle反而会浪费时间
1
2
3
4
5# 禁用Nagle
int option = 1;
setsockopt(sock, SOL_SOCKET, SO_NODELAY, (void*)&option, sizeof(option));
6.windows的区别就是
- getsockopy()的&len,len类型要从socklen_t改成int
- setsockopt()第4个参数要由(void*)&改成(char*)&
九、多进程服务器端
1.常用的并发服务器端实现方法
- 多进程服务器:通过创建多个进程提供服务
- 多路复用服务器:通过捆绑并统一管理I/O对象提供服务
- 多线程服务器:通过生成与客户端等量的线程提供服务
2.调用fork()函数创建进程
- 头文件#include <unistd.h>
- fork会创建一个正在运行的进程的副本,内存空间也复制,而不是共享,fork函数调用后的语句都会被两个进程执行
- 父进程的fork返回值为子进程pid,子进程fork返回值为0(用以区分父进程和子进程)
1
2
3
4
5
6
7pid_t pid;
pid = fork();
if(pid == 0) print("子进程");
else print("父进程");
# 运行一遍该代码,会同时打印出"子进程"、"父进程"
3.销毁僵尸进程的函数(头文件都是#include <sys/wait.h>)
- wait()
1
2
3
4
5
6
7
8
9
10int status;
wait(&status);
bool falg = WIFEXITED(status);
int temple = WEXITSTATUS(status);
# falg存销毁的子进程是否时正常退出,temple存的是终止子进程时return或exit()传的参数
# 若有多个子进程,需要多次调用wait()
# 若调用wait()时没有已终止的子进程,就会产生阻塞,直到有子进程终止 - waitpid()
1
2
3
4
5
6
7
8int status;
pid_t pid = fork();
waitpid(pid, &status, WNOHANG);
# 若把pid改成-1则表示可以销毁任意pid的子进程, WIFEXITED(status)和WEXITSTATUS(status)用法和wait一样
# 成功时返回pid或0,失败返回-1,不会阻塞
4.子进程的销毁方式
- 第一步:调用exit(参数) 或 main函数中执行return 参数
- 第二步:调用wait() 或 waitpid()
5.信号机制就是当子进程满足某条件时,父进程立马调用相关函数对其进行响应
- 函数signal()
- 头文件#include <signal.h>
- 第一个参数是特殊情况条件
- SIGCHLD:子进程终止
- SIGINT:输入CTRL+C
- SIGALRM:已到调用alarm函数注册的时间
- 通过alam(2)这种形式设定时间
- 第二个参数是满足条件时要去执行的函数
- 该函数返回值必须是void类型
- 该函数有一个int参数,该参数存的是signal()的第一个参数
- 更稳定的函数sigaction()
- 先声明结构体struct sigaction act;
- 用act.sa_handler指定要调用的处理函数的地址
- 初始化act的sa_mask和sa_flags
- 用sigaction(特殊情况条件, &act, 0)注册
1
2
3
4
5
6
7
8
9void timeout(int sig){}
struct sigaction act;
act.sa_handler = timeout;
sigemptyset(act.sa_mask); //填充0
act.sa_falgs = 0;
sigaction(SIGALRM, &act, 0);
alarm(2);
6.套接字不属于进程,而是属于操作系统,同时复制套接字会导致一个端口多个套接字使用也不合理
- 若一个套接字存在两个文件描述符,只有这两个文件描述符都终止或销毁后,才能销毁该套接字(即套接字不能close关闭,而是close关闭它的所有文件描述符后,系统会自动关闭套接字)
- 所有fork()后的子进程应该先close(serv_sock),此时关闭的也只是套接字的文件描述符
7.I/O分割例子:父进程执行读操作,子进程执行写操作
8.在未注册SIGINT信号的情况下输出CTRL+C,会用系统默认的事件处理器终止程序
9.使用管道pipe()进行通信(IPC就是进程之间通信)
- 头文件是#include <unistd.h>
- 管道和套接字一样属于操作系统,因此fork不会复制
- 执行完pipe后,fds[0]存管道出口,fds[1]存管道入口
- 管道里的数据是无主的,即如果子进程write后立马read,那么父进程就读不到,解决办法是write后加sleep()或再创建一个管道
1
2
3
4
5
6
7
8
9
10
11int fds[2];
pipe(fds)
pid=fork();
if(pid==0){
write(fds[1], str, sizeof(str);
}
else read(fds[0], buf, sizeof(buf)-1);
# 以上实现的是子进程向父进程传输字符串
十、用select实现I/O复用
1.I/O复用实现并发服务端,原理是时分复用,用一个服务端进程为多个客户端服务
2.select函数的调用过程
- 设置文件描述符、指定监视范围、设置超时
- 调用select
- 查看调用结果
3.select设置文件描述符
- 使用fd_set位数组来存放,1表示该文件描述符是监视对象
- 赋值完成后要用一个新的位数组temps复制,因为调用select会改变fd_set的值
1
2
3
4
5fd_set reads, temps
FD_ZERO(&reads); //将fd_set的所有位设为0
FD_SET(0, &reads); //将文件描述符0的fd_set位设为1,即开始监视为0的文件描述符
FD_CLR(0, &reads); //将文件描述符0的fd_set位设为0,即不再监视为0的文件描述符
4.select设置监视范围和超时
- 第一个参数是int,设置监视范围
1
int maxfd; //maxfd的值是需要监视的文件描述符的数量,一般为最新建文件描述符的值+1
- 第二个参数是fd_set数组,用来关注是否有待读取数据的文件描述符,没有就写0
- 第三个参数是fd_set数组,用来关注是否可无阻塞传输数据的文件描述符,没有就写0
- 第四个参数是fd_set数组,用来关注是否发生异常的文件描述符,没有就写0
- 第五个参数是结构体timeval,设置超时(注意,每次调用select前都要重新设置一遍)
1
2
3struct timeval timeout;
timeout.tv_sec = 5; //设置5秒
timeout.tv_usec = 5000; //设置5000微妙
5.调用select(windows用法也一模一样,只不过windows的第一个参数是为了兼容性才加的,没有实际意义)
- 原则上会把fd_set的所有位清0,但监视中的且发生变化的文件描述符除外,即调用select后还为1的文件描述符表示其发生了变化(用FD_ISSET(i, &fd_set)来判断,如果符合变化条件就返回true)
- 返回值就是发生了特定事件的文件描述符数目,超时返回0,错误返回-1
1
2
3#include <sys/select.h>
int fd_num = select(fd_mad, &fd_set, &fd_set, &fd_set, &timeout);
十一、其他的I/O函数
1.send和recv函数的最后一个参数是可选项(不使用时传0)
- MSG_OOB:用于发送紧急消息,但是并不能提高传输速率
- OOB是指out-of-band,事实上TCP并不存在真正意义上的“带外数据”(通过完全不同的通信路径传输的数据),MSG_OOB也只是通过信号处理函数urg_handler传输1个字节(相当于一个标志,告诉我这个数据是紧急的),其他数据还是用普通方式传输,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18# 服务端中使用send函数并用MSG_OOB关键字的信息会优先发送
send(sock, "4", strlen("4", MSG_OOB);
# 客户端收取则通过信号机制来完成(windows用select完成)
struct sigaction act;
act.sa_handler = urg_handler;
sigemptyset(&act, sa_mask);
act.sa_falgs = 0;
fcntl(recv_sock, F_SETOWN, getpid()); //通过fcntl可以改变已打开的文件性质
int state = sigaction(SIGURG, &act, 0);
void urg_handler(int signo){
...
recv(recv_sock, buf, sizeof(buf)-1, MSG_OOB);
...
} - MSG_PEEK|MSG_DONTWAIT使用这种方式的参数时:用于以非阻塞方式验证输入缓冲中是否存在接受的数据
- MSG_PEEK调用recv函数时,即使读取了也不会删除输入缓冲中的数据,即该部分数据仍存在缓冲中可供后续读取
2.readv和writev函数用于对数据进行整合后再接受和发送,用于减少I/O调用提高通信效率
- 头文件#include <sys/uio.h>
- 第一个参数是int类型的文件描述符
- 第二个参数是struct iovec类型的数组,负责存数据的保存位置和大小
- 第三个参数是第二个参数的大小
- 适用情况:传输数据位于不同缓冲数组时,或多次传输时
1
2
3
4
5
6
7
8
9
10
11
12
13struct iovec vec[2];
char buf1[]="ABCD", buf2[]="1234";
vec[0].iov_base=buf1;
vec[0].iov_len=3; //缓冲设为3,即会发送ABC,再发送D
vec[1].iov_base=buf2;
vec[1].iov_len=2; ////缓冲设为2,即会发送12,再发送34
writev(client_sock, vec, 2); //2表示有vec[0]和vec[1]
# 接受
char buf1[100]={0,}, buf2[100]={0,};
//其余与上面一模一样
readv(client_sock, vec, 2); //2表示有vec[0]和vec[1]
十二、多播与广播
1.多播的数据传输是基于UDP完成的(所以创建的套接字需要时UDP套接字),不同的是多播是同时向多个主机传递数据
- 每次多播服务器端都只对特定多播组发1次数据
- 加入特定组即可接受发往该多播组的数据
- 加入多播组可以理解为:我希望接受发往目标239.234.218.234(D类ip地址)的多播数据
- 多播发送的数据包,由路由器复制成n份再分别发往n个客户端
2.TTL和加入多播组
- TTL就是Time to Live,决定的是数据包传递距离,每经过一个路由器TTL值就要减一,TTL为0时该数据包只能被销毁不能再传递(设置过大的TTL会影响网络流量,设置过小的TTL会导致无法传递给目标)
- 设置TLL通过设置套接字选项完成,协议层IPPROTO_IP,选项名IP_MULTICAST_TTL
1
2int time_live = 64;
setsockopt(sock, IPPOTO_IP, IP_MULTICAST_TTL, (void*) &time_live, sizeof(time_live)); - 设置加入多播组也是通过设置套接字选项完成,协议层IPPROTO_IP,选项名IP_ADD_MEMBERSHIP
1
2
3
4
5struct ip_mreq join_adr;
join\_adr.imr\_multiaddr.s\_addr = "多播ip地址";
join\_adr.imr\_interface.s\_addr = "要加入多播组的套接字所属主机的ip地址"; //也可以直接使用INADDR_ANY
setsockopt(sock, IPPOTO_IP, IP_ADD_MEMBERSHIP, (void*) &join_adr, sizeof(join_adr));
3.多播的发送数据和接受数据(和UDP通信一模一样)
- 发送端
- 套接字的ip地址要写成多播地址
- sendto()之前要设置TTL
- 接收端
- 套接字的ip地址要写成多播地址
- recvfrom()之前要设置加入多播组
4.多播是基于MBone这个虚拟网络工作的,虚拟网络指“不是实际存在的物理网络,而是由网络中的某协议产生的概念性的网络”
5.多播和广播的区别
- 多播的对象是不同网络的主机们;广播的对象是同一网络的主机们
6.广播是向同一网络中的所有主机传输数据的方法
- 直接广播:向xx.xx.xx.255发送数据,会将数据发送给xx.xx.xx上的所有主机
- 本地广播:向255.255.255.255发送数据,会将数据发送给自己所在网络上的所有主机
- 开启广播设置
1
2
3int bcast = 1;
setsockopt(sock, SQL_SOCKET, SO_BROADCAST, (void*)& bcast, sizeof(bcast));
- 把发送端的ip地址改一下并把设置TTL换成开启广播设置,把接收端加入多播组去掉并ip地址填INADDR_ANY,即可把多播改成广播
十三、套接字和标准I/O
1.使用标准I/O函数时会得到系统额外的缓冲支持,这个缓冲和套接字的缓冲不是同一个
- 使用标准I/O函数传输数据时,会经历两个输入缓冲(或两个输出缓冲)
- 位置关系是服务端/客户端 - I/O函数缓冲 - 套接字缓冲 - 网络
2.一个最简单的复制文件写法(不是标准I/O所以不是基于缓冲的)
1 | int len; |
复制文件功能:基于缓冲的标准I/O写法(fopen、fgets、fputs(fputc是写单个字符)、fclose)-特别快
1 | int len; |
3.文件描述符和FILE指针可以互相转换
- fdopen()用于将FILE指针转换成对应的FILE指针
1
fdopen(fd_file, "w");
- fileno()用于将FILE指针转换成对应的文件描述符
1
fileno(fd_int);
4.标准I/O函数为了提高性能,内部提供额外的缓冲,因此需要调用fflush函数来保证立即将数据传输到客户端
- flush()功能是冲洗流中的信息,会强迫将缓冲区内的数据写到FILE指针中
1
fflush(fd_file);
5.标准I/O函数优点
- 良好移植性:所有标准函数都是按ANSIC标准定义的,支持所有操作系统(编译器)
- 可以利用额外缓冲提高性能:本来要分10次发送的数据可以堆积到缓冲只发送1次,减少头部文件和数据向输出缓冲移动的次数
6.stderr是标准错误文件的文件描述符,可以直接往里面写数据
十四、关于I/O分流
1.I/O分流是一种思想,指的是将写入和输出的文件描述符分开
- 全关闭:用两个指向同一套接字的file指针分别分担写入和输出的工作,但是fclose关闭任意一个file指针都会导致两个一起关闭,原理是它俩其实指向的是同一文件描述符
- 半关闭:在创建两个指向同一套接字的file指针之前,先对该套接字复制一个额外的文件描述符即可实现,fclose关闭其中一个file指针不影响另一个
- 复制文件描述符操作
1
2
3
4
5
6# 第一种方式
int fd_copy = dup(fd);
# 第二种方式,除了会指定fd_copy = xx之外和第一种方式并无区别
int fd_copy = dup2(fd, xx);
- 复制文件描述符操作
2.会向另一端发送EOF的情形
- 调用close()、fclose()等
- ctrl+c终止
十五、优于select的epoll
1.select的I/O复用服务器端优点和缺陷
- 优点:兼容性好,适用大部分操作系统;设置简单,服务端接入数少的时候优先
- 缺陷:
- 调用select后要对所有文件描述符暴力遍历去检查状态
- 每次调用select都需要向系统传递一个全新的监视对象组信息(因为select会改变fd_set变量,所有要复制一个fd_set作备份)
- 这个会造成比遍历更大的开销,因为程序向系统传递数据很造成很大负担,且无法通过优化代码解决,是硬伤
- 因为select是监视套接字标号的函数,而套接字是由操作系统管理的,所以select必须借助操作系统才能完成功能
- 有一种弥补方法是select仅向系统传递一次监视对象,当监视对象变化时,只通知变化的事项
2.Linux的epoll函数
- 头文件#include <sys/epoll.h>
- 三个函数
- epoll_create:用于创建保存epoll文件描述符的空间
1
int epfd = epoll_creat(50); //50是向系统建议epoll例程的大小,现在的操作系统会完全忽略这个参数而自己设置
- epoll_ctl:向空间注册并注销文件描述符
- 第一个参数是用于注册监视对象的eoll例程文件描述符,即epoll_create()的返回值
- 第二个参数表示要注册还是删除的选项
- EPOLL_CTL_ADD意味添加
- EPOLL_CTL_DEL意味删除,删除时第四个参数事件类型填NULL
- EPOLL_CTL_MOD意味更改已经注册过的文件描述符的关注事件类型
- 第三个参数是需要注册的监视对象的描述符
- 第四个参数是监视对象的事件类型组地址,即结构体struct epoll_event变量的引用
- epoll_wait:与select类似,等待文件描述符发生变化
- 第一个参数是用于注册监视对象的eoll例程文件描述符,即epoll_create()的返回值
- 第二个参数是struct epoll_event *指针变量,且要用xx=malloc(sizeof(struct epoll_event)*EPOLL_SIZE));来分配缓冲空间
- 第三个参数是int类型,代表第二个参数中可以保存的最大事件数,即EPOLL_SIZE
- 第四个参数是int类型,表示等待时间,默认单位是0.001秒,为-1时表示一直等待直到发生
- epoll_create:用于创建保存epoll文件描述符的空间
- 结构体struct epoll_event
- 成员events是事件类型
- EPOLLIN是需要读取数据的情形
- EPOLLOUT是输出缓冲为空,可以立即发送数据的情形
- EPOLLPRI收到OOB数据的情形
- EPOLLRDHUP断开连接或处于半关闭的情形,用于边缘触发
- EPOLLERR发送错误的情形
- EPOLLET以边缘触发的方式得到事件通知
- EPOLLONESHOT发生一次事件后相应文件描述符不再收到事件通知,因此需要用EPOLL_CTL_MOD来重新设置
- 成员data.fd是套接字的文件描述符,表示将该套接字注册到epoll例程中
- 成员events是事件类型
3.一个简单的epoll使用(写在服务器端,实现回声功能)
1 | int EPOLL_SIZE = 50; |
4.epoll事件的触发分为
- 条件触发:默认触发方式,输入缓冲收到数据后注册,后面只要输入缓冲中不为空,就继续以该事件的方式注册(select也是条件触发)
- 边缘触发:只有在输入缓冲收到数据时注册一次,后面不注册,因此在一次读取中应该用代码读取完所有缓冲中的数据,比起条件触发要在read()外面加上个while循环
- 边缘触发方式应该用非阻塞方式运行的read&write函数,用阻塞方式运行的话会引起服务器长时间的停顿
- 将套接字改为非阻塞模式的方法
1
2int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag|O_NONBLOCK); - 将EPOLL改为边缘触发的方法,事件类型后面加上|EPOLLET
1
event_ctl.events = EPOLLIN|EPOLLET;
- 判断读完输入缓冲中的数据方法(如果read()返回0,需要注销监视并且关闭套接字后跳出)
- read()返回-1
- errno == EAGAIN; //需在头文件额外引入#include <errno.h>,里面包含了一个名为errno的全局变量
- 将套接字改为非阻塞模式的方法
- 边缘触发方式应该用非阻塞方式运行的read&write函数,用阻塞方式运行的话会引起服务器长时间的停顿
5.边缘触发的最大优势在于能够分离数据的接受和处理时间点,在实际业务中边缘触发更有可能带来高性能,但不能一定说边缘触发性能一定比条件触发好
- 因为边缘触发只会注册一次,所有系统可以自由决定什么时候接受和处理
- 而条件触发如果不读取的话,会一直反复注册,就算用epoll_wait()来解决的话,也会出现事件数累积的问题
十六、多线程服务器的实现
1.使用多进程实现的服务器缺点
- 创建进程的过程会带来一定的开销
- 进程间通信需要特殊的IPC技术(管道、信号、套接字等等都算)
- 每秒少则数十次、多则数千次的上下文切换是创建进程时最大的开销(原因是因为只有一个CPU,为了分时使用COU需要大量上下文切换)
- 上下文切换:当前运行进程A,但下一步需要运行进程B时,需要把A的相关信息移出内存(放到硬盘里)而把B的相关信息放进来
2.线程创建函数pthread_create()具有单独的执行流,因此需要单独定义线程的main()函数
- 含线程的代码,在编译(g++、gcc)过程中要添加-lpthread选项声明连接线程库,这样采才能调用<pthread.h>中的函数
1
g++ thread01.cpp -o thread01 -lpthread
- 头文件#include <pthread.h>
- 第一个参数是pthread_t类型的空变量,用于保存新创建线程ID的变量地址值
- 第二个参数是指定线程属性的参数,用NULL表示使用默认属性
- 第三个参数是线程void*类型的main()函数名的引用
- 一般用void* thread_main(void *arg),arg就是第四个参数传给线程的变量
- 第四个参数是(void*)&类型,用于向线程main()里面传参数
- 一旦使用pthread_create()后就会创建新的线程并且去执行第三个参数的函数,期间进程的int main()照常运行,所有线程相关的代码中必须适当等待线程执行,不然进程一终止线程也会终止
3.一个简单线程创建和执行流程
1 | #include <stdio.h> |
4.用于让进程等待的函数pthread_join()
- 第一个参数是pthread_t类型
- 第二个参数是void*类型的引用,用于保存执行线程的函数最后返回给主函数的指针
- 调用该函数的进程(或线程)将进入等待状态,直到第一个参数的线程终止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int main{
...
pthread_t t_id;
void *thr_ret;
......
pthread_create(&t_id, NULL, thread_main, (void*)&thread_param);
pthread_join(t_id, &thr_ret);
free(thr_ret); //这里就能用free释放线程main中分配的msg空间了
return 0;
}
void* thread_main(void *arg){
char *msg = (char*)malloc(sizeof(char)*50); //给msg指向的地址分配50个char大小的空间
return (void*)msg; //把msg强转成void*指针传给pthread_join的thr_ret
}
5.所有函数可以分为:
- 线程安全函数:多个线程同时调用也不会引发问题,大部分标准函数是线程安全的,后缀是_r的也是线程安全的
- 非线程安全函数:多个线程同时调用会引发问题,gethostbyname()
所以含有线程的代码在编译时加上-D_REENTRANT表示,如果函数有线程安全版本(后缀多一个_r)的话自动调用该版本1
g++ -D_REENTRANT thread01.cpp -o thread01 -lpthread
6.工作线程模型(Woker)
- 创建多个线程
- main()只负责输出结果,不负责具体工作
- 具体工作分别由线程完成
- 一个简单的分别计算1-5和6-10之和的工作线程模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23#include <pthread.h>
void * thread_summation(void * arg);
int sum = 0;
int main(int argc, char *argv[]){
pthread_t id_t1, id_t2;
int range1[] = {1, 5};
int range2[] = {6, 10};
pthread_create(&id_t1, NULL,thread_summation, (void *)range1);
pthread_create(&id_t2, NULL,thread_summation, (void *)range2);
pthread_join(id_t1, NULL);
pthread_join(id_t2, NULL);
printf("result:%d \n", sum);
return 0;
}
void * thread_summation(void * arg){ //重点!!此时arg指向的是数组range[]首地址
int start = ((int*)arg)[0]; //要先把arg转换成int*指针
int end = ((int*)arg)[1]; //然后就用[0]和[1]这样的形式来取得值,不要使用++的方法
for(int i = start; i <= end; i++) sum += i;
return NULL;
}
7.线程改变全局变量值的过程
- 线程获取全局变量当前值
- 传到CPU进行计算获得计算后的值
- 将计算后的值传回变量来更新其值
第二步和第三步之间完全有可能会有另一个线程获取CPU,然后使用旧的值进行计算,最终造成结果不正确,所以线程同步很重要
8.多线程概念中的临界区
- 定义:同时运行多个线程时会引起问题的代码块
- 出问题的全局变量不属于临界区,因为它是一个内存区域的声明,不是多条代码语句
- 临界区通常位于由线程运行的函数内部,通常是那些用到同一内存区域的代码语句
9.线程同步的方法:互斥量
- 头文件就是线程的头文件#include <pthread.h>
- lock和unlock用时会很长
- 初始化函数pthread_mutex_init()
- 第一个参数是pthread_mutex_t类型变量的引用
- 第二个参数是属性,一般传NULL
- 销毁函数pthread_mutex_destroy(),参数是pthread_mutex_t类型变量的引用
- 锁住临界区函数pthread_mutex_lock
- 参数是pthread_mutex_t类型变量的引用
- 在临界区开始时调用,当执行到该行时会检测临界区内是否有其他线程,若有则等待其退出临界区
- 释放临界区函数pthread_mutex_unlock,参数是pthread_mutex_t类型变量的引用
10.线程同步的另一种方法:信号量
- 头文件#include <semaphore.h>
- 初始化函数sem_init()
- 第一个参数是sem_t类型的引用
- 第二个参数是int类型,表示可以由多个进程共享,创建1个进程内部使用的信号量时传0
- 第三参数是指定新创建的信号量初始值,该值在调用sem_post时会+1,调用sem_wait时会减1
- 销毁函数sem_destroy(),参数是sem_t类型的引用
- 释放临界区函数sem_wait
- 参数是sem_t类型的引用
- 当信号量的值为0时,进入阻塞,直到其他线程执行了post使得信号量的值+1
- 释放临界区函数sem_post,参数是sem_t类型的引用
11.线程销毁函数
- 调用pthred_join除了会等待,还会自动引导其销毁
- 调用pthread_detach()会直接销毁,参数是pthread_t类型
十七、制作HTTP服务器端
1.HTTP服务器基本知识
- Web服务器就是基于HTTP协议,将网页对应的文件传输给客户端的服务器端
- 超文本就是可跳转的文本,即文件传输到浏览器上展示,而我们可以通过点击展示的文本跳转到其他页面
- HTTP协议时基于TCP/IP实现的应用层协议
- 浏览器属于基于套接字的客户端,当连接到任意Web服务器时,浏览器内部也会创建套接字,只不过浏览器多了一项将HTML超文本解析成友好型的视图
- Web服务器端在响应客户端请求后,立马断开连接,即使同一客户端马上再请求,服务器端也无法辨别还是原先那个客户端,所以HTTP又称“无状态的Statekess协议”(为了弥补HTTP无法保持连接的缺点,通常采用Cookie和Session技术将之前信息保存在本地)
2.客户端向Web服务器端发送请求消息的结构
- 请求行:GET/POST方式、网页文件路径、HTTP协议版本等,只能是1行,因此会自动识别第一行是请求行
- 消息头:用户信息、发出请求的浏览器的信息
- 1个空行
- 消息体:只有POST方式才有消息体,向服务器端传递数据
3.Web服务器端响应消息的结构
- 状态行:使用的HTTP协议版本、是否成功
- 消息头:服务器端信息和要传输的数据的格式
- 1个空行
- 消息体:客户端请求的数据
C++网络编程体会
1.线程函数里面最好不要使用c语言的str库函数,容易莫名其妙core dumped
2.fopen()中的文件路径不能直接用string类型变量,因为原型是char *,所以string变量要写成xx.c_str()的形式
3.widows浏览器访问虚拟机里的web服务器设置
- 虚拟机网络适配器设置NAT模式:用于共享主机IP地址
- 在虚拟机里使用ifconfig查询ip地址
- Web服务器端设置ip地址为上一步查到的地址
- 启动Web服务
- 在widows浏览器中输入该ip地址即可实现访问
4.localhost、127.0.0.1和本机ip地址的区别
- localhost是指向127.0.0.1的域名
- 127.0.0.1用于本地应用之间连接,不需要有网络
- 本机ip是供外网进行数据交流的地址,需要网络