1. 网络通信原理:
1.1 CS和BS架构
客户端软件要是想将数据交给服务端,它就必须调用计算机硬件(网卡),让网卡将数据发给服务端计算机的网卡.服务端的计算机网卡,将数据交给它的操作系统,再交给服务端软件.这样就是完成了数据的传输.
这个过程是主动的,当服务端网卡收到数据的时候,会被操作系统放入内存.而服务端软件会主动向操作系统发起系统调用.问操作系统有没有我的数据.操作系统说有,服务端软件就可以拿到它要的数据了,占用的内存就可以回收了.
BS本质上也是CS架构,只是将浏览器当作客户端..BS有一个好处,就是开发的时候,只需要开发服务端.用户打开浏览器之后,只需要输入网址(对应服务端的ip地址).就可以通过浏览器和服务端进行通信了.
1.2 物理连接介质
不管是CS架构还是BS架构,都是需要网络进行通信的,而我们的电脑要想进行网络通信,一定需要物理连接介质.
网卡每台电脑都有网卡,不论是有线网卡还是无限网卡
交换机:可以连接电脑,路由器,存在主力交换机性能比较好
光猫:实现电信号和光信号的转换.
1.3 七层模型
1.3.1通信存在的问题
网络通信,不是简单的连接网线就可以实现通信.还存在很多问题:
多系统:我们使用的设备很多,每种设备使用的系统不同
多介质:联网的方式有很多,有的是通过网线,有的是通过WiFi,这是不同的介质
目标问题:一台电脑发送数据如何准确的发送到另一台电脑上面去.
多软件同时使用网络:如何避免在微信上面发送的消息,不会发送到QQ上面去
要想数据进行传输,网络就要进行设计,无论是光纤,网线还是WiFi,只要大家遵循相同的协议,数据就可以进行传输了.网络上有很多协议但是不论哪种协议,都要遵循osi七层模型进行设计..就是用这个七层模型作为参考来设计协议.
1.3.2 内容
应->表->会->传->网->数->物
osi 七层模型
物理层:解决信号转换的问题
不论在信号传递的过程中使用的什么信号,光纤,网线还是WiFi,最终都是会转换成为数字信号(0101).当我们的数据往外发的时候,网卡会将数字信号转换为电信号.如果使用的光纤,电脑上面就是会存在光纤网卡,将数字信号转换为光信号.
数据链路层:mac地址,全世界唯一,身份证号,解决发给谁的问题
每一块网卡上面都存在mac地址.mac地址全世界唯一,就解决了目标问题.数据就不会发错人了.
网络层:ip地址,主要用于定位,解决发到哪里的问题
IP地址分为两种公网ip和内网ip,IP主要用于定位的,今天在安徽联网,公网就是安徽的,明天在重庆联网,公网ip就是重庆的
公网ip 全世界唯一 类似于快递的收货地址
内网ip 局域网唯一 类似于酒店的房间号码,在不同的内网里面就可以存在相同的内网ip
传输层:解决的是使用什么方式发
tcp协议:可靠,速度慢,每次发送一个包,都会进行一次确认,适合长距离传输
UDP协议,不可靠,速度快, 只管发送,收没收到它 ,适合短距离传输
端口:可以让一台计算机上面的多个程序同时使用网络,当某个APP发送数据的时候可以加上端口号,接收数据的时候就可以通过端口号,知道传输的数据是哪个程序使用的了
会话层:决定什么时候开始发,什么时候开始断.
我们在正式发送数据包的时候,如果是使用TCP的话,会话层会先发送一个探测包,探测网络是否畅通.如果网络不通,后面的数据就不用再发送.如果是通畅的,就进行拆包.加上编号,拆成一个个小包一个个发送.
对于目标计算机而言,就是一个个收包.再合成起来.并且告诉对方,数据收集完成,可以断开连接了.
表示层:
用来描述文件类型,将我们发送的那部分文件前面加上文件类型
应用层:
就是我们的程序或说进程,用QQ或者微信传输文件,这些文件本质上面都是二进制,拿到这些文件知道该如何打开呢?
基于这个模型设计出来的协议非常多,使用最多的一个叫做TCP/IP协议.
1.4 封包和解包
TCP/IP协议是五层模型,它将后三层合并成为了应用层.
如何理解协议:?用来规定数据的组织格式的.
协议:数据+头部
封包的过程就是给数据加上头部,拆包的过程就是去掉头部,拿到真正的数据.
封包:
1)应用层产生了一段数据,会根据自己的协议套一个头部,先统称为数据
2)传输层会加一个头部叫做TCP头部,如果数据很大的话,会拆成很多个小包,就是很多的头部+数据
3)到网络层会再加上一个ip头部
4)数据链路层有一个以太网协议,再加上一个以太网头部
5)传到物理层时,将整个数据包进行信号转换,将数字信号转换为电信号,WiFi就是转为电磁波信号
通过网线将信号传给对方的计算机,对方计算机按照同样的协议反向操作
解包:
1)从物理层到数据链路层,计算机会看以太网头部,这个以太网头部里面存的就是mac地址,如果mac地址是找我的就将数据收起来,将以太网头部去掉,继续传给网络层
2)这个时候网络层就会看ip地址,ip地址是找我的,就将ip头部去掉,继续传给传输层
3)传输层就会看端口,看数据是传给哪个进程的,将tcp头部也去掉,应用层就可以拿到自己的数据了.
1.5 抓包
ping命令就是一个包探测器,它可以探测本机和目标计算机的网络是否通畅
1.6 五层模型
1.6.1 物理层
一组数据称之为一个bit流
物理层是基于电信号,光信号和电磁波信号进行通信的.但是如果仅仅只是01011000这种代码是没有任何意义的.所以这种信号必须有头有尾,人们才能理解其含义.才能将01101的数据映射成为我们的数据.
1.6.2 数据链路层
存在一个以太网协议Ethernet,我们学习一个协议,就是看它里面规定了哪些内容.此协议规定
一组数据称之为一个数据帧,一个数据帧分为两部分(头部)+(数据:网络层所有的内容) 头部里面就是(发送者mac,接收者mac,类型),以太网里面的发送者和接收者指的就是mac地址.所以说设备要联网必须有网卡,每一块网卡都有全世界独一无二的mac地址,这就是以太网协议规定的.
里面的数据包含了前面所有层打包之后的结果,它的长度是1500Byte,如果长度超过了则会进行切片.这样一个数据帧既有头也有尾,就解决了前面的问题.在看似大量没有意义的二进制中读取一个个数据帧.
工作方式:广播
所以有了以太网协议之后,它使用广播的方式,理论上就可以实现全世界的计算机通信了.但是前提是全世界的计算机都是在一个小房间里面,这个小房间就相当于广播域,在广播域里面的计算机,通过广播通信,都可以接收到(暂时理解为连接到同一个交换机上的计算机).即使是有这么大的交换机也会有新的问题.
广播包的特点:
会发给广播域内所有的计算机,如果在这个广播域内所有计算机都在发送信息,这个数据量就是灾难性的,被称为广播风暴
1.6.3 网络层
1.6.3.1 广播分域
网络层存在的作用就是把全世界的计算机,放到一个个小小的广播域(局域网)里面.现在在自己的局域网里面广播,其他广播域里面的计算机是接收不到的.但是现在又带来了新的问题,如何跨局域网通信?
1.6.3.2 跨局域网通信
网络层规定,每个小房间都应该有一个出口(网关),网关应该有两个地址,一个是局域网地址,我们局域网的设备要找网关,就找这个地址.还有一个对外的地址,这个对外的地址是和公网联通的
A局域网数据->A局域网网关->公网->B局域网网关->B局域网电脑
1.6.3.4 协议: IP协议
一组数据称之为一个数据包,一个数据包分为两部分(头部)+(数据:传输层所有的内容) 头部里面就是(发送者ip,接收者ip,类型)
1.6.3.5 NAT技术
IP地址:ipv4,每一个ip地址都是由32位二进制组成,为了方便记忆,又将32位二进制分为四组 ,每一组中都有256种变化.全世界的ip地址一共大约是43亿个.很明显现在的对于全世界而言这些ip地址是不够用的.现在有两种解决方案1)让一部分ip地址可以重复使用->nat技术(Network Address Translation),把一部分ip地址专门给局域网使用,所有的内网计算机都使用这一部分ip地址.
当我们的数据包要发公网的时候:
局域网数据->网关(做一次nat地址转换,将内网ip转换为网关的公网ip)->公网(公网ip是全世界唯一,这样就可以找到正确的发送路径)
数据回来的时候还是通过nat转换将公网ip转为内网ip.
00000000.00000000.00000000.00000000 => 0.0.0.0 (最小的ip地址)
0.0.0.0
0.0.0.1
...
0.0.1.255
....
11111111.11111111.11111111.11111111 => 255.255.255.255 (最大的ip地址)
1.6.3.6 内网网段
10.0.0.0~10.255.255.255 一千六百万
172.16.0.0~172.31.255.255 104万
192.168.0.0~192.168.255.255 65536个255*255
127.0.0.0~127.255.255.255 保留地址,如果数据包是127开头的ip地址,那么这个数据包不会离开本机,一般用于测试
只要不再以上内网网段的ip都是属于公网ip
1.6.3.7 IPV6
为了解决ip地址不够用的问题,还有一种方式就是使用ipv6,分为8组,每组4个16进制数
fe80::8c9:8240:e329:b107%en0,::表示省略,128位的ip地址的数量多到无法想象
1.6.3.8 子网掩码
子网掩码的长度和ip地址的长度一样,也是由32位二进制组成,ip地址必须和子网掩码配合使用.或者说合法的ip地址,就是和子网掩码组成的
为什么要有子网掩码?
为了解决ip地址不够用的问题,划分了很多局域网.而子网掩码配合广播域使用就是为了区分不同的广播域..单纯的ip地址并不能让我知道自己在哪个广播域里面,所以必须配合子网掩码.如果不是在同一个网段即便是物理线路联通,它们也是不可以通信的.
192.168.3.88/255.255.255.0 => 11111111.11111111.11111111.00000000
192.168.3.88/24 =>就是24个1
1.6.3.9 子网掩码区分广播域
子网掩码的前24位都是1,表示前24位都不变,这24位就是用来表示网络位的,网络位不可变.主机位是可以变的.所以子网掩码就是用来固定网络位的.只要是网络位相同的ip地址,他们就是属于同一个网段,在同一个网段下,只要是物理线路,它们就可以通信.
255.255.255.0 => 11111111.11111111.11111111.00000000
192.168.3.88 => 11000000.10101000.00000011.01011000
[ 网络位 ] [主机位]
192.168.3.0 => 11000000.10101000.00000011.00000000 作为网络号使用,用来标识这个网络 第一个ip地址
192.168.3.1 => 11000000.10101000.00000011.00000001 第一个可用ip地址
......
192.168.3.254 => 11000000.10101000.00000011.11111110 最后一个可用ip地址
192.168.3.255 => 11000000.10101000.00000011.11111111 作为广播地址使用 最后一个ip地址
192.168.3.125/25和192.168.3.130/25两台主机物理线路连通之后,能否直接通信?
192.168.3.125/25 => 11000000.10101000.00000011.01111101
192.168.3.130/25 => 11000000.10101000.00000011.10000010
1.6.3.10 子网掩码计算网络位
ip地址和子网掩码按位与运算(同时为1才为1,否则为0)
192.168.3.125/25 => 11000000.10101000.00000011.01111101
25 => 11111111.11111111.11111111.100000000
11000000.10101000.00000011.00000000
ip计算器
1.6.3.11 ARP 协议
1.6.3.11.1 引入ARP协议的原因
局域网通信,使用二层交换机(将数据解析到第二层,数据链路层,数据层的头是mac地址,所以二层交换机拿到我们的数据之后,它可以看到我们的mac地址,交换机看到mac地址,就知道这个数据该发给谁了.二层交换机解析不到第三层,也就是说它解析不到ip地址,所以只是知道ip地址,是没有办法将数据发给对方的)交换机需要知道对方的mac地址,才知道下一步将数据发给谁,否则只能广播给每个人都转发一份.这个时候就需要用到ARP协议(地址解析协议),他工作在二层和三层之间,就是数据链路层和网络层之间
它的作用就是将ip地址解析成为mac地址
1.6.3.11.2 ARP广播流程
只是知道对方的ip地址不知道对方的mac地址的时候,需要先发送一条arp广播,,这个广播的内容大概是"谁是192.168.3.6?把你的mac地址给192.168.3.5",这个广播会被交换机转发给局域网内所有的计算机.192.168.3.6的计算机收到这条广播之后,会给192.168.3.5回一条数据"我是192.168.3.6,我的mac地址是xxxxxx"这时候192.168.3.5拿到这个mac地址之后,会做一个表格记录.记录这个ip地址对应的mac地址,这个表格叫做arp缓存表,这个缓存不是永久的.不同系统的缓存时间不同.也被成为arp表的老化时间.到时间就会重新发送广播,多次广播没有回应的话,这一项就会从arp表中删除.
1.6.3.12 SNAT
两台计算机要想通信,就必须知道对方的ip地址.在发送数据之前,我们会通过ip地址和子网掩码进行计算,判断对方是否是和我在同一个子网里面.如果在一个局域网里面,那就通过arp协议拿到对方的mac地址,然后将数据通过交换机传给它.如果两者不再同一个子网内,我们发送数据还是通过arp协议,这次拿的就是网关的mac地址,网关的ip地址我们的计算机里面是有的.所以只要有网关的ip地址,通过arp协议,就一定能拿到网关的mac地址.
路由器上面的lan口和wan口接入的就是不同的子网,所以我们从局域网访问公网,就是跨子网通信.
SNAT(源地址转换)是NAT的一种,简单的说就是将我们源ip(192.168.3.88)转换为公网ip(182.151.22.141)作为源ip,路由器接收到数据之后.会执行反向SNAT,会将目标ip转换为192.168.3.88.
1.6.3.13 DNAT
目标地址转换.如果我们的计算机要对外提供服务,公网发过来的请求是不能直接到达内网计算机的
....
1.6.4 传输层
1.6.4.1 传输层引入
mac地址仅限于局域网通信,跨局域网通信只需要ip地址,是不需要mac地址的.跨局域网的时候,mac地址仅仅是让我们找到网关.或者是让网关找到局域网的主机.
局域网通信: (源mac,目标mac) (源ip,目标ip)数据
跨局域网通信: (源mac,网关mac) (源ip,目标ip)数据
学习网络编程的目的就是写一个客户端软件,再写一个服务端软件,让他们进行网络通信.通信的时候就要定位到它在全世界的哪一个位置
ip+mac地址就可以定位到全世界任意一台计算机了,arp协议的作用是将ip地址解析为mac地址,所以只需要ip地址就可以定位到世界任意一台计算机了.但是现在定位的是运行在计算机上的某一款软件.这时候就需要用到第四层,传输层里面的tcp/udp协议,它给我定义了一个端口的概念,端口不仅仅是nat转换的时候起到作用,还能够帮我们定位到计算机上的某一款应用程序.
ip+mac地址+端口 => 定位到全世界内运行的唯一一款基于网络通信的应用程序.
1.6.4.2 TCP标识
tcp/udp
tcp头部+数据/udp头部+数据.所以不管tcp头部还是udp头部里面都是包含源端口和目标端口.
在传输层一组数据称之为一个数据段,每一段数据都有一个编号->数据段序列号
tcp协议:
源端口,目标端口,数据段序列号
前面说过tcp协议是可靠的,因为每一个数据段都是需要对方确认的,如果数据段发送之后,没有回应.就会默认这个数据段丢失,就会重新发送.但是tcp不是一开始就发送数据端的.在发送数据之前会发送一个探测包,探测网络是否通畅,不通畅就不发数据了.
tcp的探测过程就叫做三次握手
这里的客户端和服务端,并不是指客户端软件和服务端软件,而是谁先发起请求,谁就是客户端.这里存在TCP6种数据包标识
握手包标识:SYN
挥手包标识:FIN,断开连接之前就是发送挥手包
数据包标识:PSH
握手弯完成之后就可以发送数据,就可以发送数据包了,对方每收到一个数据,都会回一个确认包.
确认包标识:ACK
重发包:RST
紧急包:URG
1.6.4.3 三次握手
1) 客户端->服务端, 连接的第一个包就是SYN,再加上序列号seq,这个序列号是随即生成的,值比较长(seq=x),还有一个序列号ack(确认上一次服务端给我们发的数据包的序列号,第一次建立连接的时候,这个值是没有的)
2) 服务端->客户端,回一个ACK确认包,加上序列号seq=y,确认序列号 ack=x+1,这个确认序列号就是之前的随机序列号+1(表示之前发送给我的序列号为x的数据包我已经收到了,同时告诉客户端下一次给我发的序列号就是x+1)
这样一来一回之后,客户端就知道了它跟服务端的线路是通畅的.但是服务端并不知道和客户端的线路是不是通畅的
3)服务端->客户端,发送SYN握手包,随机序列号还是seq=y,确认序列号 ack=x+1
4)客户端->服务端,发送ACK确认包,数据段序列号seq=x+1,再加上确认序列号y+1(告诉服务端有这个数据包已经收到了,下次发y+1就行了)当客户端发完这个确认包之后,它们就会进入连接状态.
发了四个包,却叫做三次握手的原因:
2)和 3)除了包的标识不一样,其他都是一样的所以可以合并为ACK,SYN,seq=y,ack=x+1;
1.6.4.4 四次挥手
四次挥手,就是数据传输完成之后,断开连接的过程
1) 客户端->服务端 先发起挥手包 FIN,ACK,seq=x,ack=y
2) 服务端->客户端 发起确认包 ACK,seq=y,ack=x+1
现在是客户端给服务端的数据已经发完,但是服务端给客户端的数据可能还没有传完.所以等服务端数据发完.等服务端数据发完之后
3) 服务端->客户端 发起挥手包 FIN,ACK seq=y,ack=x+1
4) 客户端-> 服务端 发送确认包 ACk,seq=x+1,ack=y+1
1.6.4.5 SYN洪水攻击
客户端在建立连接和断开连接的时候会有几种状态.
如果我们的服务端长时间处于SYN_RCVD这个状态,就很有可能遇到SYN洪水攻击(攻击者模拟大量的假客户端对我们的服务端发起请求,让服务端的资源被占满了,导致正常的客户端进不来),攻击者用大量的假ip发送请求,发完请求它就没有了,然后切换ip继续发,也有人将这种请求称之为半连接请求,因为请求发了一半就没有了.
服务端还以为是正常请求还在回SYN,ACK对方根本收不到,也不会收.所以服务端一致处于SYN_RCVD状态当服务端一直没有收到客户端确认包时,就会重新发一次SYN,ACK直到超时,才会删除连接
1.6.4.5 TCP连接状态
除了SYN洪水攻击,正常的连接请求也有可能是洪水效果.就是高并发的时候,就是并发量已经超过了服务端的承受极限了,大量的客户在发送SYN,服务端在回SYN ACK的时候资源被占用太多,这时候它发送SYN,ACK可能就比较慢.客户端的ACK可能回来的比较慢.服务端的状态也就可能一致处于SYN_RCVD状态
1.6.4.6 传输层头部
1.6.5 应用层
传输层以下的操作都很复杂且固定,有人已经帮组我们封装好了,封装成为一个个模块,应用程序要发数据的时候,就调用提供的接口即可.所以在传输层和应用层=之间还有一个socket抽象层,这一层不属于互联网通信协议,它只是对传输层及以下做了个封装.所以说我们以后写应用程序,只需要和套接字打交道就可以了.数据涉及到网络传输才会和套接字打交道.简单的说套接字就是我们用来通过网络收发数据的
1.7 dhcp
动态主机配置协议
当我们电脑插上网线,手机连上WiFi,并没有让我们配置ip地址,为什么也可以上网?因为dhcp协议的存在它会自动帮助我们配置ip地址,子网掩码,网关地址等.
1.8 DNS
1.8.1 IP通讯录
ip地址太多,我们也需要一个通讯录,只需要记住名字,不需要记住复杂的ip地址.有人写了一个程序,浏览器访问网站的时候,就会自动读取这个文件,就可以拿到对应的ip地址了.但是我们每次上网只需要一个网站的ip地址,不需要下载所有网站的通讯录,这样太过浪费资源.所以使用文件记录ip地址的方式,慢慢也被淘汰了.
1.8.2 DNS的由来
搭建了一个dns服务端,将原来通讯录里面的ip地址都放在了dns服务端里面.而每台电脑上面也安装了dns客户端,当上网的时候,dns客户端会直接向dns服务端发送一个请求.
dns服务端口 53
web服务端口 80/443
1.8.3 常用dns
阿里: 223.5.5.5 223.6.6.6
腾讯: 119.29.29.29
百度:180.76.76.76
谷歌: 8.8.8.8
1.8.4 dns解析流程
1.8.5 url
mac地址+ip+端口+url => 定位到唯一的一篇文章了
一个完整的url包含3部分:应用层协议+ip,端口,路径下面的文件
百度安全验证https://www.baidu.com/s?ie=UTF-8&wd=csdn
2. 套接字
2.1 socket对象-服务端
基于网络通信: AF_INET,
所使用的协议:
socket.SOCK_STREAM 流式协议,tcp协议
socket.SOCK_DGRAM 数据报协议,udp协议
服务端:
1.创建socket对象
获得一个基于网络通信和基于tcp协议通信的套接字对象sk
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 流式协议(tcp协议)
2.绑定地址
sk.bind(("127.0.0.1", 5000))
服务端要绑定地址,并且这个地址是固定的.要定位到全世界唯一一个基于网络通信的应用程序,需要ip+mac+端口,mac地址可以省略,所以绑定地址的时候只需要绑定ip+端口即可.(ip,端口).
注意服务端绑定的ip是服务端所在计算机的ip地址
我们现在电脑的ip都是私网ip,也就是说绑定这个私网ip的话,只有我们这个局域网里面的主机客户端才可以访问.如果我们的服务端想要被跨局域网的客户端访问,就必须绑定一个公网ip.但是家庭宽带没有固定的公网ip,运营商也不支持端口外放,所以暂时只能在局域网里面玩.
端口除了自己的ip,还可以使用127.0.0.1,这是本地的一个回环地址,如果写了这个地址,就只能在本机测试,局域网内的其他主机也是访问不了的
也可以写一个全0地址,全0地址是一个特殊的ip地址,代表本机的所有ip地址.
端口的范围是0~65535,1024之前的端口都是系统保留的.所以最好不要使用1024之前的端口.
3.监听连接请求 (开始营业)
服务端跑起来之后,就会进入一个listen状态,等待客户端向我们发起连接,这个5就是半连接池的大小.半连接就是客户端想服务端发起了连接请求,服务端还没有向客户端发起连接请求.
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
4.取出连接请求,开始服务
如果半连接池里面没有请求进来,代码会阻塞在这里,不会执行此行代码..只有半连接池里面有请求了,才会从半连接池里面取出一个连接,然后回复SYN进行第二次握手,客户端进来是第一次握手.
我在这里调用accept()取连接的时候,它的底层就会自动回复syn,进行三次握手,并把连接建立好.
conn是连接对象,addr是客户端的ip和端口
当然我们只能一个个服务,如果同时来两个请求,只能一个先开始服务,另外一个呆在半连接池里面..前面的服务完成之后,才从连接池里面取出来进行服务.如果同时来的请求超过5个,第6个请求就会被拒绝.
conn,addr = sk.accept()
print("连接对象",conn)
print("客户端ip+端口",addr)
5.数据传输
接收客户端发过来的数据,1024就是一次最大接收的数据量单位是字节Bytes.不过收到的数据是Bytes类型,在python里面二进制数据就是bytes类型.不管是文本文件还是图片文件通过b模式读取之后,都是bytes类型.所以要进行解码.
conn.recv(1024)
给客户端发送数据
conn.send()
data = conn.recv()
data = data.decode("utf-8")
print(f"客户端发送过来的数据{data}")
data.send(data.upper())
6.结束服务
调用conn.close()就可以将连接关掉,连接关掉之后,就可以继续待用accept服务下一个用户.当然不关闭也可以继续从accept服务下一个用户.accept一次就可以从半连接池里面取出一个连接对象.套接字以下的都是操作系统的资源,所以我们应该程序结束之前,将操作系统的资源全部回收掉.调用这个close()就类似与在做4次挥手,结束此次连接.
conn.close()
7.关闭服务端
这一步可做可不做,但是一般都不做,理想的状态就是在服务端运行开始的那一刻,运行到天荒地老.
sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/26上午10:14
# name: server.py
# comment:
import socket
# 1.创建socket对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 流式协议(tcp协议)
# 2.绑定地址
sk.bind(("127.0.0.1", 5001))
# 3.监听连接请求
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
# 4.取出连接,进行服务
conn, addr = sk.accept()
print("连接对象", conn)
print("客户端ip+端口", addr)
# 5.数据传输
data = conn.recv(1024)
data = data.decode("utf-8")
print(f"客户端发送过来的数据:{data}")
conn.send(data.upper().encode("utf-8"))
# 6.结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
2.2 socket对象-客户端
1.创建socket对象
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
2.建连接
客户端也是需要ip和端口,但是我们不会绑定ip,因为自己所在的位置不一样,ip可能就会发生改变.端口也不能固定,避免自己电脑上面的端口冲突.客户端要给服务端发送数据第一件事,就是发三次握手建连接.当然这个连接不需要我们自己去建,调用sk.connect()之后,它首先会发送一个syn握手包,服务端的底层会自动回应三次握手并建立连接,同时将连接对象放入帮半连接池.这个连接请求就会去到服务端的半连接池..
sk.connect(("127.0.0.1",5000))
3.传输数据
sk.send('hello'.encode("utf-8"))
data = sk.recv(1024)
print("接收服务端发送过来的数据",data.decode('utf-8'))
4.关闭连接
客户端的close()必须写,它对应的是服务端的conn
sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/27下午6:41
# name: client.py
# comment:
import socket
# 1.创建socket对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("127.0.0.1", 5000))
# 3.传输数据
sk.send('hello'.encode("utf-8"))
data = sk.recv(1024)
print("接收服务端发送过来的数据", data.decode('utf-8'))
# 4.关闭连接
sk.close()
2.3 解决程序中存在的问题
2.3.1 通信循环
将发送数据的两部分放入while循环里面,即可实现通信循环.
# 5.数据传输 --> 服务端
while True:
try: # 解决windows电脑客户端异常断开
data = conn.recv(1024)
except:
break
if not data: # 程序收到数据为空,表示客户端异常断开了
break
data = data.decode("utf-8")
print(f"客户端发送过来的数据:{data}")
conn.send(data.upper().encode("utf-8"))
while True: #数据传输 --> 客户端
img = input(">>>")
sk.send(img.encode("utf-8"))
sk.send("hello".encode("utf-8"))
data = sk.recv(1024)
print("接收服务端发送过来的数据:", data.decode('utf-8'))
2.3.2 针对客户端异常断开的情况
对于mac和linux系统而言数据会不断的收空数据,对于windows系统而言,服务端会直接崩溃,报错.对于windows系统的报错直接使用try,except进行处理
while True:
try: # 解决windows电脑客户端异常断开
data = conn.recv(1024)
except:
break
if not data: # 程序收到数据为空,表示客户端异常断开了
break
data = data.decode("utf-8")
print(f"客户端发送过来的数据:{data}")
conn.send(data.upper().encode("utf-8"))
2.3.3 发空问题
发送是可以发送为空的,但是接收数据是收不到空的,如果接收为空就表示客户端异常断开了.收不到就要在原地等着收
实际上客户端的send和服务端的recv不是一一对应的,客户端可以send 100次,但是服务端只是recv一次.应用程序是运行在操作系统之上,我们应用程序想要发数据出去,就是要通过网卡.但是应用程序不能直接操作硬件,必须向操作系统发起请求,让操作系统调用网卡帮我们把数据发出去.
我们在使用send发送数据的时候,不是send给了服务端,而是在向操作系统发请求,让操作系统帮我们做一系列的封包操作,最后调用网卡帮我们把数据发送出去.
我们使用send发送的数据,其实是把数据放到了缓存里面,操作系统再从缓存里面一点点把数据帮我们发送出去.这个数据到对方网卡的时候,也会被操作系统放到缓存里面.recv收数据的时候其实是从缓存里面将数据取出来.
简单的说send就是将数据放入了自己的缓存里面,recv就是从自己的缓存里面取数据.send和recv多少次都是和对方没有关系的
send空的时候,确实发起了一次系统调用,但是操作系统一看缓存是空的,操作系统就不会帮我们发送数据出去,服务端也就收不到数据
解决此问题,就是发数据之前先判断一下,为空就直接跳过不发.
# 3.传输数据 -- >客户端
while True:
img = input(">>>")
if not img: #解决发空问题
continue
sk.send(img.encode("utf-8"))
sk.send("hello".encode("utf-8"))
2.3.4 连接循环
现在的服务端一次只能服务一个人,服务完之后就会挂掉.但是需要的是服务完成一个人之后,继续服务另外一个人.理想状况是持续的提供服务的同时,并发的提供服务.但是现在不考虑并发,只是让服务端持续的提供服务.
将获取连接的代码放入while循环,conn.close()关闭一个连接之后,再调用accept()取出一个连接.
while True: # 服务端 --> 连接循环
# 4.取出连接,进行服务
conn, addr = sk.accept()
print("连接对象", conn)
print("客户端ip+端口", addr)
# 5.数据传输
while True:
try: # 解决windows电脑客户端异常断开
data = conn.recv(1024)
except:
break
if not data: # 程序收到数据为空,表示客户端异常断开了
break
data = data.decode("utf-8")
if data == "q":
break
print(f"客户端发送过来的数据:{data}")
conn.send(data.upper().encode("utf-8"))
# 6.结束服务
conn.close()
2.3.5 半连接池
前面说过半连接池的大小不用设置的很大,设置的太大没有意义.学完并发之后,可以做到服务端同时服务很多客户端.也就是说accept可以一直从半连接池里面拿连接对象.给用户的感觉就是延迟很低,基本上只要你访问它,就可以马上获得结果.所以将半连接池设置的很大也没有意义.
如果客户的访问量很大,大的已经超出了服务端的并发能力.半连接池设置的很大的话,客户就会呆在半连接池里面,没法享受到即时服务.半连接池的大小改变不了用户体验,访问不了还是访问不了.真正要提高的是服务端的并发能力.
一个python文件运行多次的方法:
运行两个客户端:
前面一个客户端还在服务中,后面的一个客户端只能在半连接池里面等着.这个时候,客户端和服务端的三次握手已经完成,就等着accept把连接对象从半连接池里面拿出去.然后服务端才能够回复客户端.
所有的半连接池的请求都会占用服务端的计算机资源,而服务端又不能及时处理这些请求,改大半连接池既不能改变客户端的体验,又会过多占用服务端的计算机资源
当请求占满半连接池,再次发送请求时,会在 sk.connect(("127.0.0.1", 5001))这一步直接阻塞,出现TimeoutError.因为半连接池已经满了,客户端在发握手包的时候,服务端不会发送握手包.客户端一直重发握手包直到超时
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:09
# name: cmd_server.py
# comment: 远程执行终端命令
import socket
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.绑定地址
sk.bind(("0.0.0.0", 5005))
# 3.监听连接请求
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
while True: # 服务端 --> 连接循环
# 4. 取出连接,进行服务
conn, addr = sk.accept() # windows客户端异常断开连接
print(f"连接对象是:{conn}")
print(f"ip和端口为:{addr}")
# 5. 数据传输
while True:
try:
data = conn.recv(1200)
except:
break
if not data: # mac客户端异常断开连接
break
data = data.decode("utf-8")
print(f"客户端发送过来的数据为{data}")
if data == "q":
break
conn.send(data.upper().encode("utf-8"))
# 6. 结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:17
# name: cmd_client.py
# comment: 远程执行终端指令
import socket
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("0.0.0.0", 5005))
# 3.传输数据
while True:
meg = input(">>>").strip()
if not meg: # 避免发空问题
continue
if meg == "q":
break
sk.send(meg.encode("utf-8"))
data = sk.recv(1024)
print(f"接收服务端发送过来的数据:{data.decode('utf-8')}")
# 4.关闭连接
sk.close()
2.4 UDP套接字
UDP是可以发空的,因为tcp是流式协议,是要有水流进入缓存才会被发送出去.而UDP是数据报协议,我们只要send一次,他就会组织一个数据报发送出去.
udp不需要连接,不需要指定半连接池的大小,accept等都是不需要的
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29上午11:27
# name: udp-server.py
# comment:
import socket
# 1.创建socket对象
sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 流式协议(tcp协议)
# 2.绑定地址
sk.bind(("127.0.0.1", 5003))
# 5.数据传输
while True:
data, addr = sk.recvfrom(1024)
print(f"客户端发送过来的数据:{data}")
if data.decode("utf-8") == "q":
break
sk.sendto(data.upper(), addr)
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29上午11:34
# name: udp-client.py
# comment:
import socket
import os
# 1.创建socket对象
sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 3.传输数据
while True:
img = input(">>>")
sk.sendto(img.encode("utf-8"),("127.0.0.1",5003))
if img == "q":
break
data,addr = sk.recvfrom(1024)
print("接收服务端发送过来的数据:", data.decode('utf-8'))
# 4.关闭连接
sk.close()
2.5 远程执行终端命令
不可以使用udp来写这个远程执行终端命令的程序,udp传输数据是不可靠的.我们可能需要通过一条命令的结果,判断下一步执行什么操作.使用udp,可能命令执行成功了,但是我们没有收到结果.
将img接收的内容作为终端命令,服务端拿到data之后,将data作为终端命令执行,再将执行之后的结果发给客户端.
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:09
# name: cmd_server.py
# comment: 远程执行终端命令
import socket
import subprocess
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.绑定地址
sk.bind(("0.0.0.0", 5001))
# 3.监听连接请求
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
while True: # 服务端 --> 连接循环
# 4. 取出连接,进行服务
conn, addr = sk.accept() # windows客户端异常断开连接
print(f"连接对象是:{conn}")
print(f"ip和端口为:{addr}")
# 5. 数据传输
while True:
try:
cmd = conn.recv(1200)
except:
break
if not cmd: # mac客户端异常断开连接
break
data = cmd.decode("utf-8")
print(f"客户端发送过来的数据为:{data}")
if data == "q":
break
# 执行终端命令
obj = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# out_res和out_err都是Bytes类型
out_res = obj.stdout.read()
out_err = obj.stderr.read()
print("管道输出:",out_res)
print("管道输出ERROR:",out_err)
conn.send(out_res)
conn.send(out_err)
# 6. 结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:17
# name: cmd_client.py
# comment: 远程执行终端指令
import socket
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("0.0.0.0", 5001))
# 3.传输数据
while True:
cmd = input("请输入终端命令>>>").strip()
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
data = sk.recv(1024)
print(f"接收服务端发送过来的数据:{data.decode('utf-8')}")
# 4.关闭连接
sk.close()
2.5.1 粘包问题引入
mac | windows | 备注 |
ls | dir | |
pwd | chdir | |
ps aux | tasklist | 查看计算机上的所有进程 |
看起来没有问题但是还是隐藏巨大的坑使用ps aux指令时发现,服务端给我们传输了 68554 bytes数据,但是客户端只是收到了1024 bytes数据.
之前说过服务端的数据是直接send到了客户端的缓存,而客户端使用recv取数据时,也是从缓存里面取数据.也就是说现在客户端只是从缓存里面取了1024 bytes数据,剩余的数据还在缓存里面.如果再次输入新的指令ls /users,新的指令的结果就会流入缓存,导致两次命令的结果粘在一起
我们调用send不是直接发送数据的,只是将数据放到了缓存,还没有开始封包.缓存里面的数据都是粘在一起的,全都是应用层的数据.
数据到了客户端的计算机也会被层层解包,最后只是剩下应用层面的数据,然后放到客户端的缓存,客户端的缓存可能还有上次的数据没有收完.这样还是会造成两次的命令粘在一起.这就是TCP粘包问题.
虽然数据是粘在一起的,但是数据的顺序不会变,先来的数据在前面,后来的数据在后面.
2.5.2 分析粘包产生的原因
有粘包产生的原因来解决问题:我们每敲一次命令,先将缓存里面的数据先收干净.然后再执行下一条命令.因为ps指令的结果只有6万多个字节,所以将客户端的recv改为10万.但是其实即使将缓存里面的数据收干净还是会存在问题,服务端->6万多,客户端->3万多
2.5.3 没收完的原因
可能存在两个原因:
1)收的太快,有可能这6万多个字节流过来的速度比较慢,服务端的数据还没有完全流入客户端的缓存,所以造成我们只是收了一部分.
在客户端收数据的时候,增加几秒delay,发现确实是这个问题导致的.但是这样的做法是不可取的.首先不管数据的大小,都会存在几秒的delay,很影响使用体验.并且recv(100000),缓存的大小是有限的,服务端如果下次向我们传输10G的数据,我们不可能直接改大recv的值.
正确的方式还是多收几次,直到将服务端的发过来的数据收完,我们才应该进入下一次循环
解决粘包问题的关键:客户端要知道服务端发过来的数据整体有多长
2) 缓存(内存空间)大小有限,缓存只是程序运行内存的一部分.ps命令的结果已经超过了缓存的大小,我们收数据的时候缓存已经占满了.我们将缓存里面的数据收完.服务端的数据才能流进来.
2.5.4 粘包问题
1)TCP是流式协议,数据像水流一样源源不断在传输,数据之前没有边界,加上客户端没有将每次命令的结果收干净,从而就会造成粘包的问题
2)TCP底层使用了一个优化算法,Nagle算法,他为了提升传输效率,发送端会把多次间隔时间较短,且数据量较小的数据,合并到一起进行封包发送
2.5.5 自定义协议
解决粘包问题的思路就是:服务端发送数据的时候,先将数据的长度发送过去,客户端收到数据的长度之后,就知道接下来该收多少才能收完.
对于服务端:
即使是将头部数据发送过去但是,头部长度和传输的数据也是粘在一起的,客户端还是不知道数据的边界,到底前面多少个字节是用来表示数据长度的.我们发的数据长度,类似于后面数据的描述信息.客户端收数据的时候,先收描述信息,才知道后面要收多少数据.我们这里的数据长度就可以看作为头部,后面的数据.所以我们需要固定头部长度,收数据的时候,不着急先收数据,先收头部
服务端固定头部长度:
对于客户端:
先收头部,再循环收数据
我们现在解决粘包问题的方法就是自定义协议,自定义协议其实就是自定义头部,头部包含的数据和固定头部的长度.只要我们的客户端和服务端都遵循这个协议,它们就可以正常通信
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:09
# name: cmd_server.py
# comment: 远程执行终端命令
import socket
import subprocess
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.绑定地址
sk.bind(("0.0.0.0", 5001))
# 3.监听连接请求
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
while True: # 服务端 --> 连接循环
# 4. 取出连接,进行服务
conn, addr = sk.accept() # windows客户端异常断开连接
print(f"连接对象是:{conn}")
print(f"ip和端口为:{addr}")
# 5. 数据传输
while True:
try:
cmd = conn.recv(1200)
except:
break
if not cmd: # mac客户端异常断开连接
break
data = cmd.decode("utf-8")
print(f"客户端发送过来的数据为:{data}")
if data == "q":
break
# 执行终端命令
obj = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# out_res和out_err都是Bytes类型
out_res = obj.stdout.read()
out_err = obj.stderr.read()
print("管道输出:",out_res)
print("管道输出ERROR:",out_err)
data_size = len(out_res)+len(out_err)
# 固定头部长度
header = bytes(str(data_size),"utf-8").zfill(8)
print(header)
conn.send(header)
conn.send(out_res)
conn.send(out_err)
# 6. 结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:17
# name: cmd_client.py
# comment: 远程执行终端指令
import socket
import time
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("0.0.0.0", 5001))
# 3.传输数据
while True:
cmd = input("请输入终端命令>>>").strip()
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
# 先拿总长度
data_size = int(sk.recv(8).decode("utf-8"))
recv_size = 0
data = b'' # 如果是大文件,就开一个文件,边收便往文件里面写
while recv_size < data_size:
res = sk.recv(1024)
recv_size += len(res)
data += res
print(len(data))
print(f"接收服务端发送过来的数据:{data.decode('utf-8')}")
# 4.关闭连接
sk.close()
2.5.6 粘包最终解决方案
头部信息里面不可能只是头部长度,可呢还有文件类型,文件的md5值等等.现在组织发文件时候的头部信息.
客户端发送命令:
get d:/a.txt
服务端:
服务端通过get命令知道要下载数据,split将其拆分成为命令+路径,根据客户端提交的路径将文件打开,循环读取文件,读取一行就send一行给客户端.上传文件也是一样的道理
因为header里面的内容是变化的,所以不太容易固定头部长度 所以我们需要给这个头部再加上一个头,这个头只放长度,固定长度的头部长度.我们要将头部长度先发送出去,再发送头部,最后将文件内容传送出去.
客户端:
# _*_ coding utf-8 _*_
# george
# time: 2024/1/30上午9:26
# name: cmd_file_server.py
# comment: 文件传输
import socket
import subprocess
import json
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.绑定地址
sk.bind(("0.0.0.0", 5001))
# 3.监听连接请求
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
while True: # 服务端 --> 连接循环
# 4. 取出连接,进行服务
conn, addr = sk.accept() # windows客户端异常断开连接
print(f"连接对象是:{conn}")
print(f"ip和端口为:{addr}")
# 5. 数据传输
while True:
try:
cmd = conn.recv(1200)
except:
break
if not cmd: # mac客户端异常断开连接
break
data = cmd.decode("utf-8")
print(f"客户端发送过来的数据为:{data}")
if data == "q":
break
# 执行终端命令
obj = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# out_res和out_err都是Bytes类型
out_res = obj.stdout.read()
out_err = obj.stderr.read()
print("管道输出:", out_res)
print("管道输出ERROR:", out_err)
data_size = len(out_res) + len(out_err)
header = {
"file_name": "a.txt",
"file_size": data_size,
"md5": "gdduwfhufbhf"
}
header_json = json.dumps(header)
header_bytes = header_json.encode("utf-8")
header_h = bytes(str(len(header_bytes)), "utf-8").zfill(4)
conn.send(header_h)
conn.send(header_bytes)
conn.send(out_res)
conn.send(out_err)
# 6. 结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/30上午9:26
# name: cmd_file_client.py
# comment: 文件传输客户端
import socket
import time
import json
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("0.0.0.0", 5001))
# 3.传输数据
while True:
cmd = input("请输入终端命令>>>").strip()
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
# 先拿头部长度
header_size = int(sk.recv(4).decode("utf-8"))
# 拿头部信息
header_json = sk.recv(header_size).decode("utf-8")
header = json.loads(header_json)
print(header)
# 取数据
data_size = header["file_size"]
recv_size = 0
data = b'' # 如果是大文件,就开一个文件,边收便往文件里面写
while recv_size < data_size:
res = sk.recv(1024)
recv_size += len(res)
data += res
print(len(data))
print(f"接收服务端发送过来的数据:{data.decode('utf-8')}")
# 4.关闭连接
sk.close()
2.5.7 基于TCP协议的文件上传和下载
通过输入get/put file_path来实现对于文件的上传和下载,缺点很多,只是对于TCP粘包问题的小测试
# _*_ coding utf-8 _*_
# george
# time: 2024/1/30上午9:26
# name: cmd_file_server.py
# comment: 文件传输服务端
import socket
import json
import hashlib
from pathlib import Path
# 获取传递文件的md5值
def get_md5(cmd_path: str) -> str:
file_path = Path(cmd_path)
with open(file_path, "rb") as f:
m1 = hashlib.md5(f.read())
return m1.hexdigest()
# 传输文件给客户端:
def transfer_to_client(request_file, conn):
f = Path(request_file)
header = {
"file_name": f.name,
"file_size": f.stat().st_size,
"md5": get_md5(request_file)
}
header_json = json.dumps(header)
header_bytes = header_json.encode("utf-8")
header_h = bytes(str(len(header_bytes)), "utf-8").zfill(4)
conn.send(header_h)
conn.send(header_bytes)
with open(request_file, "rb") as f:
while True:
res = f.read(1024)
if not res:
break
conn.send(res)
print("文件传输完毕")
def receive_from_client(conn):
# 先拿头部长度
header_size = int(conn.recv(4).decode("utf-8"))
print("hear_size",header_size)
# 拿头部信息
header_json = conn.recv(header_size).decode("utf-8")
header = json.loads(header_json)
print(f"客户端传递文件的头部:{header}")
data_size = header["file_size"]
recv_size = 0
# data = b'' # 如果是大文件,就开一个文件,边收便往文件里面写
save_file_path = Path.home() / "Desktop" / "server" / header["file_name"]
# 文件接收
with open(save_file_path, "wb") as f:
while recv_size < data_size:
res = conn.recv(1024)
recv_size += len(res)
f.write(res)
print("数据接收成功")
# MD5 check
save_file_md5 = get_md5(save_file_path)
if save_file_md5 == header["md5"]:
print("MD5 check pass 数据完整性校验成功")
else:
print("MD5 check fail 数据完整性校验失败")
def main():
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.bind(("0.0.0.0", 5002))
sk.listen(5)
print("服务端启动成功,在5000端口等待连接")
while True: # 服务端 --> 连接循环
conn, addr = sk.accept() # windows客户端异常断开连接
print(f"连接对象是:{conn}")
print(f"ip和端口为:{addr}")
# 5. 数据传输
while True:
try:
cmd = conn.recv(1200)
except:
break
if not cmd: # mac客户端异常断开连接
break
data = cmd.decode("utf-8")
print(f"客户端发送过来的数据为:{data}")
if data == "q":
break
op, client_file_path = data.split()
if op == "get":
transfer_to_client(client_file_path, conn)
if op == "put":
receive_from_client(conn)
# 6. 结束服务
conn.close()
# 7. 关闭server(可选)
# sk.close()
if __name__ == "__main__":
main()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/30上午9:26
# name: cmd_file_client.py
# comment: 文件传输客户端
import socket
import json
import hashlib
from pathlib import Path
# 获取传递文件的md5值
def get_md5(cmd_path: str) -> str:
file_path = Path(cmd_path)
with open(file_path, "rb") as f:
m1 = hashlib.md5(f.read())
return m1.hexdigest()
# 下载文件
def download_file(cmd):
while True:
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
# 先拿头部长度
header_size = int(sk.recv(4).decode("utf-8"))
# 拿头部信息
header_json = sk.recv(header_size).decode("utf-8")
header = json.loads(header_json)
print(header)
data_size = header["file_size"]
recv_size = 0
# data = b'' # 如果是大文件,就开一个文件,边收便往文件里面写
save_file_path = Path.home() / "Desktop" / "tt" / header["file_name"]
# 文件接收
with open(save_file_path, "wb") as f:
while recv_size < data_size:
res = sk.recv(1024)
recv_size += len(res)
f.write(res)
print("数据接收成功")
# MD5 check
save_file_md5 = get_md5(save_file_path)
if save_file_md5 == header["md5"]:
print("MD5 check pass 数据完整性校验成功")
break
else:
print("MD5 check fail 数据完整性校验失败")
break
# 4.关闭连接
sk.close()
# 上传文件
def upload_file(cmd):
while True:
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
op, request_file = cmd.split()
f = Path(request_file)
header = {
"file_name": f.name,
"file_size": f.stat().st_size,
"md5": get_md5(request_file)
}
header_json = json.dumps(header)
header_bytes = header_json.encode("utf-8")
header_h = bytes(str(len(header_bytes)), "utf-8").zfill(4)
sk.send(header_h)
sk.send(header_bytes)
with open(request_file, "rb") as f:
while True:
res = f.read(1024)
if not res:
break
sk.send(res)
print("文件传输完毕")
break
# 4.关闭连接
sk.close()
if __name__ == "__main__":
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.connect(("0.0.0.0", 5002))
cmd = input("请输入文件传入路径>>>").strip()
op = cmd.split()[0]
if op == "get":
download_file(cmd)
if op == "put":
upload_file(cmd)
else:
exit(0)
2.6 udp不会粘包
udp协议是面向消息的,是数据报协议,就是每一条消息都是一条完整的报文,也就是说它的数据都是有边界的,它不会想TCP那样出现粘包问题.对于tcp来说,我们这样连续send两次数据,这样的两次数据就会像水流一样粘在一起送给对方,对方可以只是接收一次.但是对于udp来说,一个send就要对应一个recv
对于tcp协议来说一定要先启动服务端,但是对于udp来说先启动哪一端都是无所谓的.因为客户端和服务端不会建立连接.如果客户端先启动,然后直接给服务端发送数据的话,也是不会报错的.因为它不会管对方收没有收到,反正它是发送出去了.
对于mac和linux电脑而言,如果如果客户端发送大量数据,服务端只能接收少量数据,剩余的数据,就会被丢弃掉.这就是udp的特点,即使收不干净也不会出现粘包问题.
在windows电脑上面发送的数据量大于接收的数据量,就会直接报错.所以一般我们不会使用udp传输大数据.udp的数据最大长度为1472Bytes
前面说过一个数据包最大为1500个字节,这个1500被称之为MTU(Maximum Transmission Unit)最大传输单元网络层的头部占用20Bytes,udp报头占用8Bytes.所以一个数据包给应用层剩下的数据量为1472 Bytes.这是一个udp包一次能够传输的最大数据量.但是这个最大数据量传输的时候并不稳定.udp协议能够稳定传输的最大数据量差不多是512Bytes
dns解析使用的就是udp协议,因为它足够快,而且dns请求需要携带的数据量也比较少
3. 并发效果的实现
3.1 TCP并发服务效果
我们现在写的服务仅仅是停留在持续服务的阶段,还不能并发的提供服务.现在就看如何实现并发效果.当然仅仅是服务端需要实现并发效果,客户端并不需要.
创建类处理连接请求
class RequestHabdle(socketserver.BaseRequestHandler):
def handle(self) :
print(self.request) # 获取到连接对象,相当于conn
print(self.client_address) # 客户端的地址
实例化对象
sk = socketserver.ThreadingTCPServer(("0.0.0.0", 5001), RequestHabdle)
sk.serve_forever(),随时来,随时提供服务,它做的事情就是连接循环做的事情等价于:
while True: # 服务端 --> 连接循环
# 4. 取出连接,进行服务
conn, addr = sk.accept()
除了帮我们循环获取连接对象之外,每获取一个连接对象,都会起一个线程
这个线程就相当于服务员,将连接对象交给新启动的线程去服务
现在的serve_forever()只是负责从半连接池里面获取连接对象,获取一个连接对象,就起一个线程,然后将连接对象交给它.
它是怎么将连接对象丢给线程的呢?
通过传的类RequestHabdle实例化一个对象,并且将连接对象的相关信息封装到这个对象里面,这里每启动一个线程,都会触发类里面的handle方法.所以在handle方法里面需要做的只有一件事->通信循环.这样就是实现了服务端的并发效果.
TCP 并发服务端:
创建类(handle方法里面实现通信循环)->类的实例化(传递ip和端口,创建的类.实例化的对象会将连接对象的相关信息全部都封装到这个对象里面)->serve_forever()(循环获取连接对象,获取一个连接对象,就起一个线程,将连接对象交给它)
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:09
# name: cmd_server.py
# comment: 远程执行终端命令
import socket
import subprocess
import socketserver
class RequestHabdle(socketserver.BaseRequestHandler): # 处理连接请求
def handle(self) -> None:
print(self.request) # 获取到连接对象,相当于conn
print(self.client_address) # 客户端的地址
# 5. 数据传输
while True:
try:
cmd = self.request.recv(1200)
except:
break
if not cmd: # mac客户端异常断开连接
break
data = cmd.decode("utf-8")
print(f"客户端发送过来的数据为:{data}")
if data == "q":
break
# 执行终端命令
obj = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# out_res和out_err都是Bytes类型
out_res = obj.stdout.read()
out_err = obj.stderr.read()
print("管道输出:", out_res)
print("管道输出ERROR:", out_err)
data_size = len(out_res) + len(out_err)
# 固定头部长度
header = bytes(str(data_size), "utf-8").zfill(8)
print(header)
self.request.send(header)
self.request.send(out_res)
self.request.send(out_err)
# 6. 结束服务
self.request.close()
sk = socketserver.ThreadingTCPServer(("0.0.0.0", 5001), RequestHabdle)
sk.serve_forever()
# 7. 关闭server(可选)
# sk.close()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29下午2:17
# name: cmd_client.py
# comment: 远程执行终端指令
import socket
import time
# 1. 创建对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2.建连接
sk.connect(("0.0.0.0", 5001))
# 3.传输数据
while True:
cmd = input("请输入终端命令>>>").strip()
if not cmd: # 避免发空问题
continue
if cmd == "q":
break
sk.send(cmd.encode("utf-8"))
# 先拿总长度
data_size = int(sk.recv(8).decode("utf-8"))
recv_size = 0
data = b'' # 如果是大文件,就开一个文件,边收便往文件里面写
while recv_size < data_size:
res = sk.recv(1024)
recv_size += len(res)
data += res
print(len(data))
print(f"接收服务端发送过来的数据:{data.decode('utf-8')}")
# 4.关闭连接
sk.close()
3.2 UDP并发效果
UDP默认是不支持并发的,看到的并发效果都是假象,仅仅是因为服务端处理的太快将第一个客户端处理完成之后,直接处理第二个.如果我们在服务端的数据处理上加上delay,就能看到明显的时间延迟.
udp并发效果实现:
self.request是一个元祖,里面包含了两个值,第一个是客户端发送过来的数据,第二个是用来给客户端回数据的socket对象
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29上午11:27
# name: udp-server.py
# comment:
import socket
import time
import socketserver
class RequestHandle(socketserver.BaseRequestHandler):
def handle(self):
print(self.request) # (客户端发过来的数据,socket对象)
print(self.client_address) # 客户端的地址
print("客户端发送过来的数据:", self.request[0])
time.sleep(int(self.request[0]))
self.request[1].sendto(self.request[0].upper(),self.client_address)
sk = socketserver.ThreadingUDPServer(("0.0.0.0", 5001), RequestHandle)
# 只是负责收数据,收完数据立马起一个线程,接下来的事情都交给线程来做
sk.serve_forever()
# _*_ coding utf-8 _*_
# george
# time: 2024/1/29上午11:34
# name: udp-client.py
# comment:
import socket
import os
# 1.创建socket对象
sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 3.传输数据
while True:
msg = input("请输入>>>").strip()
if msg == "q":
break
sk.sendto(msg.encode("utf-8"), ("127.0.0.1", 5001))
data, addr = sk.recvfrom(1024)
print(data.decode("utf-8"))
# 4.关闭连接
sk.close()
4. 单道与多道
4.1 单道技术(串行)
原理:
我们现在执行任务a和任务b:
首先任务a的代码要先读入内存->cpu执行这个代码->输出设备进行输出->任务b读入内存->执行任务b->输出任务b的结果
图
4.2 多道技术
允许多个应用程序同时进入内存,并且cpu交替执行,当一道程序因为I/O请求暂停运行时,cpu便会立即运行另外一道程序
核心:切换+保存状态
cpu切换的时候分为两种情况:
1) 当一个程序遇到I/O操作的时候,操作系统会剥夺程序cpu的执行权限
2) 当一个程序长时间占用cpu的时候,操作系统也会剥夺该程序的cpu执行权限
目的:让单核实现并发效果
并发:看起来像是同时运行
并行:真正意义上的同时执行
单核不能实现并行,但是可以实现并发
原理:
任务a的代码从硬盘读入内存->cpu从内存里面取指令运行(cpu在取指令运行的时候,硬盘是空闲的状态) | 同步将任务b的代码也读入内存 -> 任务a执行结束输出结果(CPU空闲下来了) | 同步cpu从内存里面取出任务b的指令运行->任务b执行结束输出结果
5. 进程
程序:存在硬盘上的一堆代码,它是'死'的
进程:表示程序正在执行的过程,它是'活'的
5.1 进程调度
1)先来先服务算法:哪个进程先来,cpu就先执行哪个进程.但是存在一个问题就是长作业先来,短作业后来,短作业也要等长作业执行完成才能执行: 3小时,3秒.3秒.所以这个算法对长作业友好,对短作业不友好.
2)短作业优先调度算法
如果有100个短作业,1个长作业.长作业得等到最后才能执行.对短作业友好对长作业不友好.
3)时间片轮转法+多级反馈队列
时间片就是将时间分为很多份,每一份就是一个时间片.假如现在来了3个进程,操作系统就会给每个进程都分配时间片,让他们每个人都先被cpu服务3秒.有的人3秒就结束了,但是有的人3秒完全不够.一个时间片不够的话,就将它放入下一级队列.下一级队列又会分配一个时间片给它.当cpu执行完第一级里面的时间片之后,就会接着来执行第二级里面的时间片.如果在第二级队列里面还是没有结束的话,就接着往下方.越往下走,任务的耗时越长,任务的优先级越低.
图
5.2 进程三状态图
创建,首先一个程序要想被执行,用户会双击这个程序,或者提交一个任务.这个程序就会从硬盘加载到内存.所有的任务要想被执行,必须经历就绪态(就是必须先准备好,等待cpu来执行)
就绪态之后就会进入进程调度->运行态,运行的时候存在几种情况.
首先是时间片运行完了,程序也执行完了,就会立刻释放相应的资源然后->退出
其次就是运行过程中遇到I/O操作(读取/写入文件,发网络请求,input等待用户输入,time.sleep(),print等),操作系统就会将cpu拿走去执行其他时间片,当前程序就会进入阻塞态.当I/O请求完成之后,就会结束阻塞态.但是它不会立马进入运行态.而是回到就绪态里面排队
还有一种情况就是分配给你的时间片运行完了之后,你还是没有结束.也没有I/O操作.这个时候你就会直接进入就绪态排队,等待cpu下一次来执行你
5.3 同步和异步
用来描述任务的提交方式的
-同步:任务提交之后,原地等待任务的返回结果,等待的过程中不做任何事情
-异步:任务提交之后,不在原地等待任务的返回结果,而是直接去做其他的事情.
任务的返回结果会有一个异步回调机制自动处理,就是异步提交的任务,只要有返回结果了,它会自动拿到这个返回结果然后处理.这里异步提交的任务,通常都是一段代码或者一个函数,现在研究的就是代码,不再是进程调度问题.
5.4 阻塞和非阻塞
描述进程的运行状态
阻塞:阻塞态
非阻塞:就绪态和运行态
以后经常出现的是这两队概念的组合:
同步阻塞,同步非阻塞,异步阻塞,异步非阻塞,异步非阻塞是效率最高的一种.
5.5 创建进程
创建进程最简单的方式,就是双击应用程序的图标.就是让操作系统将你双击的应用程序的代码从硬盘加载到内存,然后cpu执行.
python创建进程的方式有很多
os.fork(),它依赖于linux系统的fork系统调用,windows系统不支持
multiprocessing模块
subprocess模块
5.5.1 multiprocessing创建进程的方式一
如果我们右键执行这个代码,就会在内存空间里面开辟一块内存空间,然后将这个代码丢进去.也就是说右键执行它,它就是一个进程.我们通过Process()再这个进程的基础上面再创建一个子进程.我们右键执行的就是主进程,通过代码创建的就是子进程.
target:将谁作为一个进程来创建,或者说要提交什么任务给子进程来执行.
args:函数传递的参数
p.start():就是告诉操作系统帮我们创建一个进程.这行代码执行之后,立马就会往后执行print("主进程"),它根本不会等待任务提交的结果.这就是异步提交任务的效果,这个任务可以是一个函数,也可以是一个方法.
所以一共就是2步:
1)获取进程对象
2)创建进程,异步提交任务
# _*_ coding utf-8 _*_
# george
# time: 2024/2/1上午9:47
# name: 创建进程.py
# comment:
from multiprocessing import Process
import time
def func(name):
print(f"{name}任务开始")
time.sleep(5)
print(f"{name}任务执行结束")
# 得到进程的操作对象
p = Process(target=func, args=("写讲话稿",))
# 创建进程
p.start()
print("主进程")
对于windows系统而言直接运行上述代码会报错:
在mac或是linux里面,创建进程的时候,他会把这个任务对应的代码以及当前进程的数据集(这里面的变量)全部都拷贝一份,然后在子进程里面再执行我们的任务.所以这个过程不会有问题.
但是在windows系统里面,它不是直接拷贝,他会用类似于模块导入的方式.在子进程里面导入模块.也就是导入当前的python文件.导入模块会将这个模块里面的代码全部都执行一遍.也就是说创建进程的代码在导入的时候也会被执行->又会以导入的方式再创建一个进程,这样进入一个死循环.这样有点类似于递归,并且这个递归没有结束条件.
解决这个问题的方式:
如果有行代码,我们指向右键运行的时候执行,但是不想它被导入的时候执行.就使用if __name__ == "__main__".将创建进程的代码放到这里面就可以了.这时候这个文件被导入__mian__下面的代码就不会被执行
# _*_ coding utf-8 _*_
# george
# time: 2024/2/1上午9:47
# name: 创建进程.py
# comment:
from multiprocessing import Process
import time
def func(name):
print(f"{name}任务开始")
time.sleep(5)
print(f"{name}任务执行结束")
if __name__ == "__main__":
# 得到进程的操作对象
p = Process(target=func, args=("写讲话稿",))
# 创建进程
p.start()
print("主进程")
第一次我们右键执行的时候,会走__main__下面的代码,然后创建一个进程,创建进程将这个文件作为模块导入执行,但是此时只会运行前面的代码,这里面的变量子进程都有了.
我们这个程序运行之后,内存里面就会开辟两块内存空间.主进程运行主进程的代码.子进程运行子进程的代码,他们是互不干涉的.在单核cpu下是并发的效果,在多核cpu下是并行的效果
5.5.1 multiprocessing创建进程的方式二
基于面向对象的方式创建,这样它会创建一个子进程,然后子进程会执行这个run方法
其实也就是两步最多是三步:
1)继承Process,并且定义run方法,run方法就是我们要异步执行的任务
2)若是要传递参数,就要通过重写父类的__init__方法来实现
3)实例化进程对象并且开启进程
# _*_ coding utf-8 _*_
# george
# time: 2024/2/1上午10:32
# name: 创建进程2.py
# comment:基于面向对象的方式
from multiprocessing import Process
import time
class MyProcess(Process):
def __init__(self, name):
super(MyProcess, self).__init__()
self.task_name = name
def run(self) -> None:
print(f"{self.task_name}任务开始")
time.sleep(10)
print(f"{self.task_name}任务结束")
if __name__ == "__main__":
p = MyProcess("写通话稿")
p.start()
print("主进程")
总结:创建进程就是在内存中申请一块独立的内存空间,把需要运行的代码放进去.多个进程之间的内存空间彼此之间是隔离的.进程与进程之间的数据,它们是没有办法直接交互的.如果想要交互可以借助第三方模块
5.6 join方法
让主线程等待子线程运行结束之后再执行(避免使用普通的调用函数的方式同步调用)
p1,p2,p3三个进程几乎是同时起来的,进程起来之后就各自运行各自的了,互相没有任何干扰.主进程执行p1.join()时,需要等待p1结束.但是p1在运行的时候,p2和p3也是在运行的.等到p1执行完毕的时候.p2和p3也都运行了1s.所以p2运行的时候,p3已经运行了2s了.所以最终结果就是3s多一点 .
当然在测试的时候每次打印的结果不一定都是按照123的任务顺序来的.因为p.start()只是告诉操作系统,帮我们创建一个进程.我们的代码是不能直接创建进程的,需要经过操作系统处理.而操作系统只是知道要创建3个进程,但是先创建哪一个,后创建哪一个都是随机的
# _*_ coding utf-8 _*_
# george
# time: 2024/2/1上午9:47
# name: 创建进程.py
# comment:
from multiprocessing import Process
import time
def func(name,n):
print(f"{name}任务开始")
time.sleep(n)
print(f"{name}任务执行结束")
if __name__ == "__main__":
start = time.time()
# 得到进程的操作对象
p1 = Process(target=func, args=("写讲话稿1",1))
p2 = Process(target=func, args=("写讲话稿2", 2))
p3 = Process(target=func, args=("写讲话稿3", 3))
# 创建进程
p1.start()
p2.start()
p3.start()
p1.join()
p2.join()
p3.join()
end =time.time()
print(f"执行时间{end-start}")
print("主进程")
正确的方式是:
每创建一个进程,就将进程对象添加到列表里面.最后再循环列表,执行join方法.这样就保证了这3个进程先创建,后面在执行join的时候,其他进程也在执行,这里一定是所有的进程,都开始运行,再添加到list里面去
# _*_ coding utf-8 _*_
# george
# time: 2024/2/1上午9:47
# name: 创建进程.py
# comment:
from multiprocessing import Process
import time
def func(name, n):
print(f"{name}任务开始")
time.sleep(n)
print(f"{name}任务执行结束")
if __name__ == "__main__":
start = time.time()
l = []
for i in range(1, 4):
p1 = Process(target=func, args=(f"写讲话稿{i}", i))
p1.start()
l.append(p1)
for p in l:
p.join()
end = time.time()
print(f"执行时间{end - start}")
print("主进程")
5.7进程间数据隔离
进程之间的数据是相互隔离的,子进程修改的age其实是自己子进程里面全局的age.因为子进程起来的时候会复制主进程的数据集,子进程的全局里面也有一个age=18,修改的是子进程自己全局的age
# _*_ coding:utf-8 _*_
# @Time : 10:46
# @Author: george
# @File : 进程数据隔离.py
# @Comment: ***
from multiprocessing import Process
age = 18
def func():
global age
age = 16
if __name__ == '__main__':
p = Process(target=func)
p.start()
p.join()
print(age)
5.8 进程号
计算机上面运行了很多的进程,为了方面区分和管理这些进程,操作系统会给每一个进程分配一个pid(Process id)进程号.在同一台计算机上面,不同进程的进程号都是独一无二的.
5.8.1 进程查询
mac查询: ps aux | grep pid
windows查询: tasklist|findstr pid
# _*_ coding:utf-8 _*_
# @Time : 11:02
# @Author: george
# @File : 进程号.py
# @Comment: ***
from multiprocessing import Process, current_process
import time
def task():
# current_process().pid 获取当前进程的进程号
print(f"当前任务正在执行{current_process().pid}")
time.sleep(100)
if __name__ == '__main__':
p = Process(target=task)
p.start()
print("主进程",current_process().pid)
除了current_process模块之外,还可以使用os模块来获取pid,os.getpid()和os.getppid()获取父进程的进程号
5.8.2 父进程
主进程就是子进程的父进程.主进程的父进程,因为python文这个进程是pycharm帮助我们创建的.所以它的父进程就是pycharm这个进程
# _*_ coding utf-8 _*_
# george
# time: 2024/2/20上午11:34
# name: 父进程.py
# comment:
from multiprocessing import Process
import os
import time
def task(name="子进程"):
print(f"{name}{os.getpid()}正在执行中")
print(f"{name}的父进程{os.getppid()}正在执行中")
time.sleep(30)
if __name__ == '__main__':
p = Process(target=task)
p.start()
print("主进程")
task("主进程")
5.8.3 杀死当前进程
p.terminate(),
windows: taskkill pid
mac/linux:kill -9 pid
5.8.4 判断当前进程是否是存活状态
p.is_alive()
5.9 僵尸进程和孤儿进程
主进程运行完毕之后,并不会结束,而是会等待子进程结束之后,主进程才会结束.这是什么原因?
5.9.1 僵尸进程
僵尸进程:子进程死活,还会有一些资源的占用(进程号,进程运行状态,运行时间等),等待父进程通过系统调用回收(子进程死后需要父进程进行收尸)除了init进程之外所有的进程最后都会步入僵尸进程.所以说僵尸进程一般都没有危害,主进程结束之后会自动回收僵尸进程.或者主进程等待子进程执行完毕之后再回收僵尸进程.
有一种情况僵尸进程存在危害:子进程退出之后,父进程没有及时处理.父进程代码也一直没有运行完毕,那么这个僵尸进程就会一直占用计算机资源,它的进程号也会被一直被占用.所以在开发过程中要尽量避免出现过多的僵尸进程(主进程一直在创建子进程,子进程结束之后,主进程没有及时回收)如果出现大量的僵尸进程会导致计算机资源被僵尸进程过度占用,同时系统可能因为没有可用的进程号,导致系统不能产生新的进程
5.9.2 孤儿进程
子进程处于存活状态,但是父进程却是意外死亡了(比如在终端里面强制将父进程杀死).这就意味着没有人给子进程收尸,没有人来回收子进程占用的资源.这时候的子进程就被成为孤儿进程.但是操作系统会开设一个孤儿院,专门用来管理"孤儿进程"回收孤儿进程相关的资源,这个孤儿院就是init进程.
5.9.3 守护进程
后台在默认运行的服务就被称之为守护进程.这些守护进程守护的就是操作系统.只要操作系统起来了,这些守护进程,就起来了.
通过p.daemon=True来设置守护进程
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21上午10:54
# name: 守护进程.py
# comment:
from multiprocessing import Process
import time
def task(name):
print(name,"正常活着")
time.sleep(3)
print(name,"正常死亡")
if __name__ == '__main__':
p = Process(target=task,args=("妲己",))
p.daemon =True
p.start()
print("纣王驾崩了")
5.10 互斥锁
实现方式:
在主进程里面生成一把锁,通过参数传递给所有的子进程,哪个步骤操作数据会导致数据错乱,就在那一步骤枷锁,处理完成之后再释放锁给下一个进程继续枪锁
比如说是12306买票,在查票的时候有票,但是在查票之后买票之前,有人已经将票全部买掉了,你就买不到了.也就是说我们在访问网页拿数据的时候,我们拿到的是这个时间点的数据.但是别人是可以针对数据做出修改的.但是我们是拿不到修改之后的数据的,除非我们刷新,重新发送请求.
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21上午11:20
# name: 互斥锁.py
# comment:
from multiprocessing import Process
import json
import random
import time
def search_ticket(name):
with open("./tickets.json", "r", encoding="utf-8") as f:
dict = json.load(f)
print(f"乘客{name}查询到还剩余票:{dict['ticket_num']}张")
def buy_ticket(name):
with open("./tickets.json", "r", encoding="utf-8") as f:
dict = json.load(f)
# 模拟网络延迟
time.sleep(random.randint(1, 5))
if dict.get("ticket_num") > 1:
dict["ticket_num"] -= 1
with open("./tickets.json", "w", encoding="utf-8") as f:
json.dump(dict,f)
print(f"乘客{name}购票成功")
else:
print(f"乘客{name}购票失败")
def task(name):
search_ticket(name)
buy_ticket(name)
if __name__ == '__main__':
for i in range(1, 9):
p = Process(target=task, args=(i,))
p.start()
明明只是剩下了2张票,但是8个人全部都买到票了.也就是说我们一张票卖给了8个人(也就是说8个人都查询到余票为2,都对余票进行了-1操作,也就是买票的这个过程数据错乱了)
当多个进程操作同一份数据的时候会出现数据错乱的问题,对于这个问题就是加锁处理
但是不要轻易加锁,加锁只应该发生在争抢数据的环节
锁的原理:将并发变为串行,虽然牺牲了运行效率,但是保证了数据的安全性.(也就是说买票的时候应该一个个来,不是所有人同时来买. -> 互斥锁.互斥锁以后基本用不到,别人都会帮助我们封装好.
先创建互斥锁->将互斥锁传给每一个子进程->哪个环节出现错乱就给那个环节加锁
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21上午11:20
# name: 互斥锁.py
# comment:
from multiprocessing import Process, Lock
import json
import random
import time
def search_ticket(name):
with open("./tickets.json", "r", encoding="utf-8") as f:
dict = json.load(f)
print(f"乘客{name}查询到还剩余票:{dict['ticket_num']}张")
def buy_ticket(name):
with open("./tickets.json", "r", encoding="utf-8") as f:
dict = json.load(f)
# 模拟网络延迟
time.sleep(random.randint(1, 5))
if dict.get("ticket_num") >= 1:
dict["ticket_num"] -= 1
with open("./tickets.json", "w", encoding="utf-8") as f:
json.dump(dict, f)
print(f"乘客{name}购票成功")
else:
print(f"乘客{name}购票失败")
def task(name, mutex):
search_ticket(name)
# 抢锁,这是随机的,我们并不能决定谁先谁后
mutex.acquire()
buy_ticket(name)
# 释放锁
mutex.release()
if __name__ == '__main__':
mutex = Lock()
for i in range(1, 9):
p = Process(target=task, args=(i, mutex))
p.start()
5.11 消息队列
实现方式:
创建一个队列对象,put放入数据,get获取数据,先进先出
前面说过,进程与进程之间的的数据是相互隔离的,那么通过什么手段可以实现进程之间的通信?
使用消息队列或是管道
队列:先进先出
堆栈:后进先出
python提供了一个模块queue,可以在内存里面帮助我们维护一个队列,同时支持我们往队列里面存取数据
导入它时有以下3种方式:
import queue
queue.Queue()
from multiprocessing import queues
queues.Queue()
from multiprocessing import Queue
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午2:00
# name: 消息队列.py
# comment:
from multiprocessing import Queue
"""
q.put()
q.get()
在多进程条件下可能会不准确
q.put_nowait()
q.get_nowait()
q.full()
q.empty()
"""
# 1.设置队列最大存储量为6,如果不传值默认为32767
q = Queue(6)
# 2.往队列里面放入数据,先进先出.
q.put("a")
q.put("b")
q.put("c")
q.put("d")
q.put("e")
q.put("f")
# q.put("g") # 超过队列存储量,进程就会阻塞在这里
# q.put_nowait("g") # 超过队列储存量直接报错 => queue.Full
# q.put("g",timeout=3) # 设置3秒超时时间,如果队列3秒之内还是满的就会直接报错 => queue.Full
q.full() # 判断队列是否满了
print(q.full())
v1 = q.get()
v2 = q.get()
v3 = q.get()
v4 = q.get()
v5 = q.get()
v6 = q.get()
print(v1, v2, v3, v4, v5, v6) # => a b c d e f
q.empty() # 判断队列是否为空
print(q.empty())
# v7 = q.get() # 队列里面只有6个,所以进程就会阻塞在这里
# q.get_nowait() # 获取数据不等待,队列里面没有数据直接报错 =>_queue.Empty
q.get(timeout=3) # 设置3秒超时时间,如果队列3秒之内还是空的就会直接报错 =>_queue.Empty
5.12 IPC 进程间通信
实现方式:
首先进程之间的通信依赖于队列,
1)在主进程里面创建队列对象,
2)通过参数传递将队列对象传递给一个子进程,
3)p.put()在队列里面放入数据,就可以通过get方法在子进程或是主进程里面获取数据了。
管道也可以实现进程之间的通信,管道和队列很像,数据在管道里面也只有一份,前面使用subprocess模块的结果,就会传给一个管道stdout,stderr,并且使用read来读取管道里面的内容.读取完之后,我们再次调用read()之后,就读取不到内容了
但是我们还是应该使用队列,因为队列就是管道+锁.队列比管道要好用的多
借助队列来实现进程之间的通信,进程之间的通信有一个专业的词叫做ipc(inter-Processs Communication)机制,也就是进程之间的通信
主进程和子进程之间基于队列的通信:
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午6:04
# name: IPC机制.py
# comment:
from multiprocessing import Process, Queue
def task(q):
q.put("宫保鸡丁")
if __name__ == '__main__':
q = Queue()
p = Process(target=task, args=(q,))
p.start()
# 不需要在此添加p.join(),因为q.get()的机制就是,队列里面没有内容就会阻塞在这里
print(f"主进程获取子进程数据:{q.get()}")
子进程和子进程之间基于队列的通信:
现在开设了两个子进程,一个用来往队列里面放数据,一个用来往队列里面取数据.
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午6:04
# name: IPC机制.py
# comment:
from multiprocessing import Process, Queue
def task(q):
q.put("宫保鸡丁")
def task2(q):
print(f"获取子进程task数据:{q.get()}")
if __name__ == '__main__':
q = Queue()
p = Process(target=task, args=(q,))
p2 = Process(target=task2, args=(q,))
p.start()
p2.start()
5.13 生产者消费者模型
实现方式:
就是两个子进程,借助队列一个put数据,一个获取数据。
1) 为了能够在数据取完之后结束进程,需要借助一个特殊的队列JoinableQueue,使用方式和Queue.在get()一个数据之后调用去。tsak_done(),告诉队列我取走一个数据,在队列里面的数据为空时,会执行q.join()
2) 但是为了避免消费者的取数据的速度过快,队列直接为空,直接执行q.join(),所以要在生产者执行结束才执行去q.join(),所以需要执行p.join()
3) 即使此时进程也没有结束,因为没有给get()设置结束条件。所以要将消费者设置成为守护进程,在主进程q.join()执行结束之后,队列里面为空时,消费者也主动死亡。
生产者:生产或是制造数据的
消费者:消费或者处理数据的
媒介:消息队列
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午6:34
# name: 生产者消费者模型.py
# comment:
import time
from multiprocessing import Process,Queue
import random
def producer(name,food,q):
for i in range(8):
time.sleep(random.randint(1,3))
print(f"{name}生产了{food}{i}")
q.put(f"{food}{i}")
def customer(name,q):
while True:
food = q.get()
time.sleep(random.randint(1,3))
print(f"{name}吃了{food}")
if __name__ == '__main__':
q = Queue()
p = Process(target=producer,args=("厨神小当家","黄金炒饭",q))
p2 = Process(target=producer, args=("神厨小福贵","佛跳墙",q,))
c = Process(target=customer,args=("八戒",q,))
c2 = Process(target=customer,args=("悟空",q,))
p.start()
p2.start()
c.start()
c2.start()
但是存在一个问题就是,消费者完成消费之后,队列里面为空,就阻塞在了q.get()这里.所以我们要在队列里面添加一个结束标志.不是有几个消费者对象就添加几个结束标志.因为队列里面的结束标志结束就没有了.
同时添加结束标志时,一定是在生产者全都生产完成之后再添加.不然先添加结束标志.会导致消费快的那个消费者先拿到结束标志而被"毒死"
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午6:34
# name: 生产者消费者模型.py
# comment:
import time
from multiprocessing import Process,Queue
import random
def producer(name,food,q):
for i in range(8):
time.sleep(random.randint(1,3))
print(f"{name}生产了{food}{i}")
q.put(f"{food}{i}")
def customer(name,q):
while True:
food = q.get()
time.sleep(random.randint(1,3))
print(f"{name}吃了{food}")
if food == "鹤顶红":
break
if __name__ == '__main__':
q = Queue()
p = Process(target=producer,args=("厨神小当家","黄金炒饭",q))
p2 = Process(target=producer, args=("神厨小福贵","佛跳墙",q,))
c = Process(target=customer,args=("八戒",q,))
c2 = Process(target=customer,args=("悟空",q,))
p.start()
p2.start()
c.start()
c2.start()
p.join()
p2.join()
q.put("鹤顶红")
q.put("鹤顶红")
除了统计消费者个数,添加结束标志之外,还可以导入JoinableQueue的库
# _*_ coding utf-8 _*_
# george
# time: 2024/2/21下午6:34
# name: 生产者消费者模型.py
# comment:
import time
from multiprocessing import Process, Queue, JoinableQueue
import random
"""
JoinableQueue
在Queue的基础上面多出来一个计数器机制,每往队列里面put一个数据计数器就+1
每调用一次task_done计数器就-1
当计数器为0的时候,就会走q.join()后面的代码
"""
def producer(name, food, q):
for i in range(8):
time.sleep(random.randint(1, 3))
print(f"{name}生产了{food}{i}")
q.put(f"{food}{i}")
def customer(name, q):
while True:
food = q.get()
time.sleep(random.randint(1, 3))
print(f"{name}吃了{food}")
q.task_done() # 告诉队列已经拿走一个数据,并且已经处理完了
if __name__ == '__main__':
q = JoinableQueue()
p = Process(target=producer, args=("厨神小当家", "黄金炒饭", q))
p2 = Process(target=producer, args=("神厨小福贵", "佛跳墙", q,))
c = Process(target=customer, args=("八戒", q,))
c2 = Process(target=customer, args=("悟空", q,))
# 需要将消费者,设置成为守护进程,也就是说主进程死,消费者死
# 避免当主进程执行到q.join()时,队列为空,消费者还是阻塞在q.get()
c.daemon = True
c2.daemon = True
p.start()
p2.start()
c.start()
c2.start()
# 为了避免消费者的速度比生产者的速度快,导致队列里面的数据为空.直接执行q.join()后面的代码
# 添加p.join(),以此来让必须等到生产者生产完成
p.join()
p2.join()
# 等待队列里面所有的数据被取完,也就是计数器为0时,再继续往后执行
q.join()
6. 线程
进程是一块独立的内存空间,每创建一个进程,内存里面就会开辟一块内存空间,进程是资源单位.真正干活的是线程,线程是执行单位.每个进程里面是自带一个线程的.cpu执行的就是这个进程里面的线程,而线程就是代码的执行过程,执行过程中所需要的数据或者说是资源都会向其所在的进程要.
创建进程
1) 申请内存空间,而这就会消耗系统资源
2) 拷贝代码,消耗资源
创建线程:在一个进程里面可以创建多个线程,在同一个进程内,多个线程之间的资源是共享的
1) 不需要再次申请内存空间
2)不需要拷贝代码
这也就是说创建线程消耗的资源远小于创建进程
6.1 创建线程
6.1.1 方式一
from threading import Thread
import time
def task(name):
print(f"{name}任务开始执行")
time.sleep(3)
print(f"{name}结束执行")
if __name__ == '__main__':
t = Thread(target=task, args=("写代码",))
t.start()
print("主进程")
创建进程时"主进程"会优先打印,但是创建线程时"写代码任务开始执行"会先打印.创建进程需要申请空间需要拷贝代码,这都是需要消耗资源,消耗时间的.我们的代码只是向操作系统提交了创建进程的系统调用,然后代码就往后走了.而代码的执行速度是很快的,发了系统调用之后程序立马往后执行了.所以创建进程的时候."主进程"会先打印
但是创建线程,执行t.start()的时候,就是告诉操作系统在当前进程里面再创建一个线程.消耗的资源非常低,可以说t.start()一执行,线程就起来了.这也进一步说明创建线程的消耗远小于创建进程的消耗.
创建线程的代码可以不写在__main__下面,现在只是基于习惯
6.1.2 方式二
# _*_ coding utf-8 _*_
# george
# time: 2024/2/23上午8:28
# name: 创建线程2.py
# comment:
from threading import Thread
import time
class MyThread(Thread):
# 我们重写了父类的__init__方法,对象查找属性的时候会先找对象自己,然后找对象的类接着找父类
# 我们重写了__init__,所以父类的__init__不可能会被找到,我们不知道父类的__init__里面做了什么,所以需要调用一下父类的__init__方法
def __init__(self, name):
super(MyThread, self).__init__() # 重用了父类的__init__
self.name = name
# 创建线程的时候会自动触发run(),我们要传递外界参数,只能给对象添加属性
def run(self) -> None:
print(f"{self.name}任务开始执行")
time.sleep(3)
print(f"{self.name}任务结束")
if __name__ == '__main__':
t = MyThread("悟空")
t.start()
print("主线程")
6.2 基于线程或进程实现tcp并发效果
服务端:
实现方式:
1)创建socket对象
2)绑定服务器的端口和地址
3)设置半连接池的大小,进行接听请求
4)进入连接循环,通过accept()获取新的socket对象conn,专门用于和客户端进行通信,
5)将通信循环包装成为一个函数,每来一个新的conn,都开启一个新的线程进行服务
# _*_ coding utf-8 _*_
# george
# time: 2024/2/23上午8:56
# name: tcp并发-服务端.py
# comment:
import socket
from multiprocessing import Process
from threading import Thread
# 创建sk对象
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 绑定地址
sk.bind(("127.0.0.1", 5000))
# 创建连接池,监听连接请求
sk.listen(5)
def task(conn,addr):
# 通信循环
while True:
# 避免windows客户端直接断开连接
try:
data = conn.recv(1024)
except:
break
# 避免mac os断开连接发空
if not data:
break
print(f"{addr}客户端发送的内容为:{data.decode('utf-8')}")
conn.send(data.upper())
conn.close()
if __name__ == '__main__':
# 连接循环,
while True:
# 获取连接对象
"""
之前的方式是sk.accept在门口接客,服务完成之后再接下一个
现在是一个人接客,接完客直接开启一个进程进行服务,再接下一个
"""
conn, addr = sk.accept()
# p = Process(target=task,args=(conn,addr,))
p = Thread(target=task, args=(conn, addr,))
p.start()
客户端:
# _*_ coding utf-8 _*_
# george
# time: 2024/2/23上午9:05
# name: tcp并发-客户端.py
# comment:
import socket
import time
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.connect(("127.0.0.1", 5000))
while True:
sk.send(b"hello")
data = sk.recv(1024)
print(f"接收到服务端发送过来的内容:{data.decode('utf-8')}")
time.sleep(2)
6.3 线程间数据共享
在子线程中修改的全局变量,会影响主线程里面的全局变量。所以线程之间的额数据是共享的。
线程中也是存在t.join()方法,让主进程等待子进程执行结束再执行
from threading import Thread
import time
def task(name):
print(f"{name}开始执行")
time.sleep(3)
print(f"{name}结束执行")
if __name__ == '__main__':
t = Thread(target=task, args=("写代码",))
t.start()
# t.join()
print("主进程")
判断创建的线程是否属于同一个进程
from threading import Thread
import os
def task():
print(f"子线程{os.getpid()}")
if __name__ == '__main__':
t = Thread(target=task)
t.start()
# t.join()
print(f"主线程{os.getpid()}")
线程间数据共享:
6.4 线程名称和数量
可以通过导入current_thread,通过current_thread.name来获取线程的名字,active_count则是用于统计活动的线程数量的
from threading import Thread,current_thread,active_count
import time
age = 18
def task():
global age
age = 16
print(f"子线程名称:{current_thread().name}")
time.sleep(1)
if __name__ == '__main__':
t = Thread(target=task)
t2 = Thread(target=task)
t.start()
t2.start()
print(f"主线程名称:{current_thread().name}")
print(f"当前活跃线程数量{active_count()}")
6.5 守护线程
实现方式是:
t.demon=True
需要注意的是,守护线程是主线程结束之后它才会陪葬,而不是说主线程代码运行完了之后,它就会陪葬。这点很重要。主进程运行完毕之后,它不会立刻结束,要等待所有子线程运行完毕之后才会结束.
主进程运行完毕之后,它不会立刻结束,要等待所有子线程运行完毕之后才会结束.因为主线程结束了, 就意味着主线程所在的进程结束了.它里面的子线程也就没办法运行了.
也是使用t.daemon = True
from threading import Thread
import time
def task1():
print("任务1开始")
time.sleep(2)
print("任务1结束")
def task2():
print("任务2开始")
time.sleep(3)
print("任务2结束")
if __name__ == '__main__':
t = Thread(target=task1)
t2 = Thread(target=task2)
t.daemon = True
t.start()
t2.start()
print("主线程")
6.6 线程互斥锁
实现方式:
在主进程里面生成一把锁,创建进程时,通过参数传递传递给异步提交的任务中。在数据处理的地方获取锁。处理完之后释放锁。和进程的互斥锁其实没有区别。
当多个人操作同一份数据,造成数据错乱,就加锁处理,将并发变为串行,虽然牺牲了运行效率,但是保证了数据的安全性
# _*_ coding utf-8 _*_
# george
# time: 2024/2/23下午7:10
# name: 互斥锁.py
# comment:
from threading import Thread
import time
num = 180
def task():
global num
temp = num # 模拟获取车票,不然的话输出就是0
time.sleep(1)
num = temp-1
if __name__ == '__main__':
P_list = []
for i in range(180):
t = Thread(target=task)
t.start()
P_list.append(t)
for i in P_list:
i.join()
print("最终结果是:",num)
线程互斥锁
from threading import Thread, Lock
import time
num = 180
def task(mutex):
global num
mutex.acquire() # 获取数据时加锁处理
temp = num # 模拟获取车票,不然的话输出就是0
time.sleep(0.05)
num = temp - 1
mutex.release() # 数据处理完成释放锁
if __name__ == '__main__':
P_list = []
mutex = Lock()
for i in range(180):
t = Thread(target=task, args=(mutex,))
t.start()
P_list.append(t)
for i in P_list:
i.join()
print("最终结果是:", num)
6.7 GIL(Global Interpreter Lock)全局解释器锁
GIL是一把互斥锁,作用在解释器上,进行加锁和释放锁。因为代码是要通过解释器来进行翻译的,cpu才能执行操作。
就是防止同一个进程下,多个线程同时执行。因为垃圾回收机制,同一个进程里面,多个线程同时执行是不安全的。(垃圾回收机制也是一个线程,他们都要找python解释器处理。存在一个临界状态就是,刚在内存空间创建了age,还没有绑定,被垃圾回收机制发现直接给清掉了)
在CPython中,全局解释器锁叫做GIL,是一把互斥锁.用来保护python对象的访问,防止多线程同时执行.也就是说在同一个进程下的多个线程,它们没有办法并行.有多个cpu都不能并行,一次只有一个cpu来执行.因为cpython的内存管理不是线程安全的,这也是GIL阻止同一个进程下多个线程同时执行的原因
问题:python的多线程好像没什么用,无法利用多核优势,即便有多个核,一次也只能使用一个
GIL不是pyhton的特点而是Cpython解释器独有的特点
python解释器版本:
Cpython
Jpython
Pypypython
内存管理(垃圾回收机制)
1) 引用计数:就是一个在内存里面的值,有引用的时候,也就是有变量名指向的时候,引用计数+1.变量名销毁时,引用计数-1.
2) 标记清除:python程序运行过程中,发现内存占用比较大的时候,就会自动停止当前程序的运行,然后扫描内存里面的变量,把那些无法从栈区引用到的值打上标记.然后同一清除,以此来释放大量内存.
3) 分代回收:有一些值的引用计数存活周期很长,如果这一类的值比较多的话,垃圾回收机制每一次都会把所有值扫描一遍,这就比较消耗资源了.所以分代回收就将这些值分成了好几代开始GC(Garbage cleaning)巡逻(垃圾回收机制扫描内存的过程),开始的时候正常巡逻,当一个值达到一定的权重之后,它还存在就将它放入到下一代.GC巡逻对于越往下面的值巡逻越低,以此来降低垃圾回收机制消耗的资源
=> 这也就是说同一个进程下面运行多线程时,垃圾回收机制是不安全的
假设在一个进程里面有多个线程在执行:
我们的代码不可以被cpu直接执行,还是需要一个python解释器.也就是说这三个线程要想执行这个代码.首先拿到python解释器才可以执行.这些线程要想执行这些代码,想先将这些代码交给解释器.cpu和解释器打交道.也就是说每一个进程下面都会有一个独立的解释器在.用来解释这个进程下面的代码
垃圾回收机制是一个功能,对于一个功能来说.要么是一个线程,要么是一个进程.垃圾回收机制是一个线程,用来管理当前进程下面垃圾的回收.
假设现在他们可以同时执行,比如我们计算机有四个核,4个cpu,每一个线程都可以拿一个cpu执行.这时候可能就存在一种极限的状况.线程2刚刚申请了age=18,内存空间将这个18申请出来了,还没有来的及绑定age.此时我们的垃圾回收机制就过来了.它一看内存里面的18没有引用计数,就直接将它回收了.如果出现这种情况的话就不太合理了-->很像多人同时操作同一份数据,造成数据错乱了.
所以cpython就有了一个GIL,在同一个进程下,让多线程不能利用多核能力,不能同时执行.也就是互斥锁的作用.谁抢到锁谁就能执行
注意:
1) GIL不是python的特点,而是Cpython解释器独有的特点
2) GIL会导致同一个进程下的多个线程不能同时执行,无法利用多核能力
3) GIL保证的是解释器级别的数据安全,也就是同一个进程下,线程与线程之间的数据安全
4) 我们写代码的时候该怎么写就怎么写,针对多个线程抢一个数据还是要加锁处理,不需要考虑GIL.
如果将延迟去掉输出的结果就是=>0
因为有GIL的存在这180个线程起来之后,他们并不是同时执行的,它们要去抢GIL,第一个线程抢到GIL之后,瞬间就执行了temp-1,然后这个线程结束,释放掉了GIL,下一个线程接着来抢GIL.
将延迟加上结果是=>179
这180个线程起来之后,都需要抢GIL,抢到GIL之后,抢到GIL之后就要睡0.05s.也就是说你进入到了IO.只要你进入了IO就需要释放GIL.你已释放GIL,其他179个线程就接着抢GIL.抢到之后也拿到了num,然后释放GIL.也就是说0.05s内,这180个线程都抢到了一次GIL.都拿到了num,此时的num值还是180.然后所有的线程都执行了180-1.理想状况下这个GIL确实可以解决我们的问题.但是网络延迟是没法解决的.
所以我们要保证num正常从180->0,我们必须要自己加一把锁..让操作这部分数据从并发变为串行.这样输出的结果就会变为0.
锁的写法也可以使用with语句,不需要手动释放锁.
# _*_ coding utf-8 _*_
# george
# time: 2024/2/24下午2:12
# name: GIL全局解释器锁.py
# comment:
from threading import Thread, Lock
import time
num = 180
def task(mutex):
global num
with mutex:
temp = num # 模拟获取车票,不然的话输出就是0
time.sleep(0.05)
num = temp - 1
if __name__ == '__main__':
P_list = []
mutex = Lock()
for i in range(180):
t = Thread(target=task,args=(mutex,))
t.start()
P_list.append(t)
for i in P_list:
i.join()
print("最终结果是:", num)
6.8 多线程与多进程的比较
问题:python的多线程好像没什么用,无法利用多核优势,即便有多个核,一次也只能使用一个
但是这是需要分情况的:
多核:执行10个任务(计算密集型/IO密集型)
计算密集型:cpu需要不断的工作,代码没有任何的I/O操作
如果每一个任务都需要10s,有10个核
多线程:同一个进程下,多线程无法利用多核能力,这10个任务执行完毕就需要100s+
多进程:我们开10个进程,就可以有10个cpu同时计算,这10个任务执行完毕只是需要10s+
如果是计算密集型,多进程是比多线程的效率高的
I/O密集型:我们的任务会频繁的出现IO操作.
在IO密集的条件下,不管是多线程还是多进程,都会频繁的出现IO操作,CPU就会频繁的切换.所以无论是多线程还是多进程使用的时间都是差不多的.因为即便是多进程可以使用多个cpu同时工作.但大多的时间都是在IO.cpu在IO的情况下是没有工作的.即便是10个cpu,10个cpu都是在闲置状态.而且多线程更加节省资源,多进程更加的浪费资源.
所以在I/O密集型的条件下,多线程是比多进程更具有优势的.所以说多线程和多进程都有各自的使用场景.不能单纯的说多线程没有用.
6.8.1 计算密集型
查询电脑的cpu核数:os.cpu_count()
# _*_ coding utf-8 _*_
# george
# time: 2024/2/26上午8:33
# name: 计算密集型.py
# comment:
from multiprocessing import Process
from threading import Thread
import time
def task():
res = 0
for i in range(10000000):
res += i
if __name__ == '__main__':
start = time.time()
l = []
for i in range(10):
p = Process(target=task)
# p = Thread(target=task)
p.start()
l.append(p)
for i in l:
i.join()
end = time.time()
print("执行时间为:", end - start)
# 执行时间为: 4.28701376914978 => 进程
# 执行时间为: 6.696681976318359 => 线程
6.8.2 IO密集型
这个时间差就是以为创建进程是需要消耗资源消耗时间的
from multiprocessing import Process
from threading import Thread
import time
def task():
time.sleep(1)
if __name__ == '__main__':
start = time.time()
l = []
for i in range(1000):
# p = Process(target=task)
p = Thread(target=task)
p.start()
l.append(p)
for i in l:
i.join()
end = time.time()
print("执行时间为:", end - start)
# 执行时间为: 4.610012769699097 => 进程
# 执行时间为: 1.1807622909545898 => 线程
总结:
1) 多进程和多线程都有各自的优势,以后写项目的时候,通常可以多进程下开设多线程(这样既可以利用多进程使用使用cpu多核能力,也可以基于多线程节省资源的消耗).
2) 现在开发的程序,90%以上,其实都是IO密集型的,多线程优势
3) 多进程使用场景(挖矿,造氢弹原子弹,训练人工智能,解决三体问题),利用cpu的多核能力
6.9 死锁
谨慎使用互斥锁,会造成死锁现象。
具体就是线程一拿到锁2等待着抢锁1,线程2抢到了锁1等着抢锁2,两个进程卡死了
死锁现象就是整个程序会阻塞住
分析:
线程1先起来,先抢锁1,其他的7个线程只能等着.
线程1抢到锁2,线程1抢锁2的时候其它7个线程还是在外面等着
线程1释放锁2,其他进程还是进不来
线程1释放锁1,这一瞬间,其他7个进程都要去抢锁1.此时线程1在抢锁2,所以线程1可以顺利抢到锁2
线程1抢到锁2之后就进入了睡眠
线程2抢到锁1之后就要抢锁2但是锁2在线程1手上.等线1睡醒之后,并没有立刻释放锁2,而是要抢锁1.而锁1在线程2手上.
所以我们不要随意使用互斥锁,很容易造成死锁现象
# _*_ coding utf-8 _*_
# george
# time: 2024/2/26上午9:09
# name: 死锁.py
# comment:
from threading import Thread, Lock, current_thread
import time
mutex1 = Lock()
mutex2 = Lock()
def task(): # 抢锁1-> 抢锁2 -> 释放锁2 -> 释放锁1
mutex1.acquire()
print(current_thread().name, "抢到锁1")
mutex2.acquire()
print(current_thread().name, "抢到锁2")
mutex2.release()
mutex1.release()
task2()
def task2():
mutex2.acquire()
print(current_thread().name, "抢到锁2")
time.sleep(1)
mutex1.acquire()
print(current_thread().name, "抢到锁1")
mutex1.release()
mutex2.release()
if __name__ == '__main__':
for i in range(8):
t = Thread(target=task)
t.start()
6.10 递归锁
简单的说就是一个人重复的抢锁和释放锁.递归锁内部有一个计数器,每acquire一次计数器就会+1,每release一次计数器就会-1,只要计数器不为0,其他人就不能抢到这把锁实现方式和创建互斥锁的方法相同,就是RLock.
from threading import Thread, Lock, current_thread,RLock
import time
# mutex1 = Lock()
# mutex2 = Lock()
mutex1 = mutex2 = RLock()
def task(): # 抢锁1-> 抢锁2 -> 释放锁2 -> 释放锁1
mutex1.acquire()
print(current_thread().name, "抢到锁1")
mutex2.acquire()
print(current_thread().name, "抢到锁2")
mutex2.release()
mutex1.release()
task2()
def task2():
mutex2.acquire()
print(current_thread().name, "抢到锁2")
time.sleep(1)
mutex1.acquire()
print(current_thread().name, "抢到锁1")
mutex1.release()
mutex2.release()
if __name__ == '__main__':
for i in range(8):
t = Thread(target=task)
t.start()
6.11 信号量
作用:用来控制同时访问特定资源的线程数量,限流。互斥锁是只能一个,信号量是控制个数。所以说信号量是可以控制个数的互斥锁
实现方式一样:
1)实例化semaphore对象
2)加锁和释放锁
信号量在不同的阶段,可能对应不同的技术点,对于并发编程来说,它指的是"锁"它可以用来控制同时访问特定资源的线程数量,通常用于某些资源有明确访问数量限制的场景,简单来说就是用于限流.例如停车场
"""
互斥锁:停车场只有一个车位
信号量:停车场有多个车位
"""
from threading import Thread,Semaphore
import time
import random
sp = Semaphore(5)
def task(name):
sp.acquire()
print(name,"抢到停车位")
time.sleep(random.randint(3,5))
sp.release()
if __name__ == '__main__':
for i in range(15):
t = Thread(target=task,args=(f"宝马{i+1}号",))
t.start()
6.12 Event事件
作用:
实现子线程或是子进程等待另外一个子线程或是子进程结束之后再继续,就需要使用到Evevt事件。
实现方式:
1)创建event对象
2)一个进程里面使用event.set()发射信号
3)一个进程里面使用event.wait()接收set发射的信号才开始执行后面的代码,否则一直不执行。
主进程或是主线程可以通过join方法,等待子进程或是子线程执行完成之后再继续.但是子进程与子进程之间或是子线程与子线程之间.它们是不能等待对方运行完毕的.
如果我们要实现一个子进程或子线程等待另外一个子进程或是子线程运行完毕再继续的话,就需要用到Event事件.
只有人执行了event.set(),event.wait()才能往后走
from threading import Thread,Event
import time
event = Event()
def bus():
print("公交车即将到站")
time.sleep(3)
print("公交车已到站")
event.set() # 发射一个信号,车来了赶快上车
def passenger(name):
print(f"{name}在等公交车")
event.wait()
print(f"{name}已经上车出发")
if __name__ == '__main__':
t = Thread(target=bus)
t.start()
for i in range(10):
t = Thread(target=passenger,args=(f"乘客{i}",))
t.start()
6.13 Queue补充
不同进程之间想要通信,需要队列作为中转.同一个进程下面的多个线程之间的数据是共享的,不需要用到队列就可以直接通信.队列就是管道+锁,多个线程操作同一份数据可能出现数据不安全的情况.所以为了数据安全线程其实也可以使用队列
现在使用的queue都仅仅限于我们在本地测试使用,帮助我们快速了解队列的特点,实际开发项目的时候.就需要用到别人开发好的一些工具.比如RabbitMQ,Redis,Kafka
6.13.1 LifoQueue
后进先出(last in first out)
import queue
q = queue.LifoQueue()
q.put("a")
q.put("b")
q.put("c")
print(q.get()) # => c
6.13.2 优先级Queue
import queue
q = queue.PriorityQueue()
# (优先级,数据),数字越小优先级越高
q.put((18,"a"))
q.put((69,"b"))
q.put((36,"c"))
q.put((-1,"d"))
print(q.get())
print(q.get())
7.TCP并发问题解决->池
服务端:
from threading import Thread
import socket
def con(conn,addr):
while True:
try:
data = conn.recv(1024)
print(f"接收到{addr}发送的数据:{data}")
if not data:
break
conn.send(data.upper())
except:
break
conn.close()
def run(ip, port):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((ip, port))
server.listen(5)
while True:
conn, addr = server.accept()
Thread(target=con, args=(conn,addr)).start()
if __name__ == '__main__':
run("127.0.0.1", 5000)
客户端:
import socket
import time
client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
client.connect(("127.0.0.1",5000))
while True:
client.send(b"hello")
data = client.recv(1024)
print(f"接收到服务端发送过来的内容:{data.decode('utf-8')}")
time.sleep(2)
对于我们现在的服务端来说,每来一个客户端就会开设一个进程或是线程去服务.但是问题是无论开设线程还是开设进程都是需要消耗资源的.只是开设线程消耗的资源比开设进程消耗的资源要小而已.假如有上亿个客户端来连接你的服务端.就意味着我们需要开设上亿个线程来处理.所以这肯定是不现实的.所以无论是进程还是线程都是无法无限制创建的.
7.1 池的概念
池是用来保证计算机硬件安全的情况下,最大限度利用计算机资源,它降低了程序的运行效率(让程序不能无限制创建进程或是线程),但是保证了计算机硬件的安全.以此来让我们的程序能够正常运行
ThreadPoolExecutor,ProcessPoolExecutor为线程池和进程池
如果使用线程池,不传递参数,默认就会开设当前计算机cpu核数*5这么多个线程
7.2 获取异步结果
只要将任务提交给线程池,线程池就会自动安排一个线程来执行这个任务.同过线程池提交的任务是异步提交.异步提交的结果就是不等待任务的执行结果,继续往下执行
提交了50个任务,但是任务是10个10个被执行的,效果和信号量很像但是本质是不一样的
信号量: 锁,线程是自己创建的,信号量可以控制线程执行,其他线程阻塞
线程池: 线程是由线程池创建的,控制的是线程的数量
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
# 不传递参数,默认开设的线程数量,是当前cpu个数*5
# 如果传递参数10,那么就会开设10个线程,中途不会销毁.因为创建线程和销毁线程也是需要资源的.
# 固定线程就可以将中途创建线程和销毁线程的资源节省下来
pool = ThreadPoolExecutor(10)
def task(name):
print(name)
time.sleep(3)
for i in range(50):
pool.submit(task, i) # 往线程池里面提交任务
当打印result()结果时,程序从10个线程并发变为串行,每个任务后面都有一个None,而None就是任务的打印结果 ,现在task函数没有返回值.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
# 不传递参数,默认开设的线程数量,是当前cpu个数*5
# 如果传递参数10,那么就会开设10个线程,中途不会销毁.因为创建线程和销毁线程也是需要资源的.
# 固定线程就可以将中途创建线程和销毁线程的资源节省下来
pool = ThreadPoolExecutor(10)
def task(name):
print(name)
time.sleep(3)
for i in range(50):
future = pool.submit(task, i) # 往线程池里面提交任务
print(future.result())
现在的问题是我们提交问题是异步提交,但是这里调用result()拿取异步结果却是同步提交,它要在原地等待任务的结果,这样异步提交的方式就没有了意义.所以我们应该先让这50个任务提交完成,然后再来获取每一个任务的返回结果
# _*_ coding utf-8 _*_
# george
# time: 2024/2/28下午2:17
# name: 线程池.py
# comment:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
# 不传递参数,默认开设的线程数量,是当前cpu个数*5
# 如果传递参数10,那么就会开设10个线程,中途不会销毁.因为创建线程和销毁线程也是需要资源的.
# 固定线程就可以将中途创建线程和销毁线程的资源节省下来
pool = ThreadPoolExecutor(10)
def task(name):
print(name)
time.sleep(3)
return name + 10
f_list = []
for i in range(50):
future = pool.submit(task, i) # 往线程池里面提交任务,异步提交
f_list.append(future) # 50个任务先提交上去,再慢慢等待任务的结果
pool.shutdown() # 关闭线程池,等待线程池中所有任务运行完毕
for i in f_list: # 此时任务的结果是立刻就拿到了,因为所有的任务都执行完毕了
print("任务结果:", i.result())
7.3 进程池
进程池的使用和线程池的使用是相同的,进程池里面的进程也不会销毁,然后重新创建
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import os
# 如果传递参数10,那么就会开设10个线程,中途不会销毁.因为创建线程和销毁线程也是需要资源的.
# 固定线程就可以将中途创建线程和销毁线程的资源节省下来
# pool = ThreadPoolExecutor(10) # 不传递参数,默认开设的线程数量,是当前cpu个数*5
pool = ProcessPoolExecutor() # 不传递参数,默认开设的线程数量,是当前cpu个数
def task(name):
print(name, os.getpid())
time.sleep(3)
return name + 10
if __name__ == '__main__': # windows 系统上开设进程的代码,必须要放到__main__下面
f_list = []
for i in range(50):
future = pool.submit(task, i) # 往线程池里面提交任务,异步提交
f_list.append(future) # 50个任务先提交上去,再慢慢等待任务的结果
pool.shutdown() # 关闭线程池,等待线程池中所有任务运行完毕
for i in f_list: # 此时任务的结果是立刻就拿到了,因为所有的任务都执行完毕了
print("任务结果:", i.result())
7.4 异步回调机制
上面的获取异步任务结果的方式并不推荐,而是使用异步回调机制.
异步回调机制就是给每一个异步任务都绑定一个函数,一旦该任务有结果,就立刻触发这个函数的执行,它是自动触发的
# _*_ coding utf-8 _*_
# george
# time: 2024/2/28下午3:17
# name: 异步回调机制.py
# comment:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import os
pool = ProcessPoolExecutor()
def task(name):
print(name, os.getpid())
time.sleep(3)
return name + 10
def call_back(res):
print("call_back", res.result())
if __name__ == '__main__':
for i in range(50):
# future = pool.submit(task, i)
# 通过submit方法拿到一个submit对象,调用future对象的add_done_callback方法,会自动将future对象传递进去
pool.submit(task, i).add_done_callback(call_back) # 给任务添加回调机制,该任务完成之后就会立刻回调
8.协程
8.1 定义
也可以称之为微线程,它是一种用户态内的上下文切换技术,简单说就是在单线程下面实现并发效果(并发是看起来同时运行,它可能也是基于切换+保存状态)
进程:资源单位
线程:执行单位
协程:其实是不存在的,计算机提供给我们的只是进程和线程.协程则是程序员人为创造出来的 ,而协程在做的事情就是切换+保存状态
CPU有两种切换,第一种就是程序遇到IO的时候会切换,,第二种就是程序长时间占用CPU的时候也会切换.对于第二种情况来说协程是没有办法避免的
我们需要做的就是在程序遇到IO的时候,通过我们写的代码,让我们的代码自动完成切换.也就是说我们通过代码来监听IO,一旦程序遇到IO就在代码层面上自动切换,给CPU的感觉就是我们的程序没有IO.换句话说就是,我们欺骗了CPU
所以如何在代码层面实现切换+保存状态呢?
8.2 切换+保存状态
切换+保存状态就是在多个任务之间来回切换,而且每一次切换之后都是接着上一次的进度继续往后执行 => yield 关键字
将yield写入函数的时候,就会将函数变为一个生成器,同时它可以帮助我们保存状态,就是将代码停在这里去执行其他的操作.下次再来执行的时候,可以基于上次的yield继续往后执行
注意: 切换不一定是提升效率,还有可能是降低效率.遇到IO切换就会提升效率,但是对于密集型任务而言,切换反而会降低效率
1) 计算密集型
# _*_ coding utf-8 _*_
# george
# time: 2024/3/6下午3:45
# name: 计算密集型切换.py
# comment:
import time
def f1():
n = 0
for i in range(10000000):
n + i
def f2():
n = 0
for i in range(10000000):
n + i
start = time.time()
f1()
f2()
end = time.time()
print(end-start) # 1.257533073425293
2) yeild关键字
# _*_ coding utf-8 _*_
# george
# time: 2024/3/6下午3:45
# name: yield.py
# comment:
import time
def f1():
n = 0
for i in range(10000000):
n + i
yield # 遇到yield之后再回到f2
def f2():
g = f1()
n = 0
for i in range(10000000):
n + i
next(g) # 调用生成器,现在f1开始执行,
start = time.time()
f2()
end = time.time()
print(end - start) # 2.274660110473633
8.3 gevent模块
yield关键字虽然可以实现切换+保存状态的效果,但是它不能做到遇到IO进行切换,计算密集型的话会降低效率.接下来要解决的问题就是对于IO密集型任务如何去监测IO