Python 网络编程

Python 网络编程

网络概述

现在的生活离不开网络,例如手机,电脑,平板,都是网络的代名词,通过一些APP,浏览器,获取大量的信息如文字、声音、视频,这都是从网络的某个地址存在的或者是网络的另一端某个用户通过设备共享的,那么什么是网络?是通过又是如何共享的尼?网络是由若干节点和连接这些节点的链路构成,表示诸多对象及其相互联系,像我们平常办理宽带与手机办卡上网,都是通过向某一服务商缴费注册连上他们的设备,下载相应的聊天软件和浏览器就可以上网与他人交流、获取资源、浏览一些网络信息。

当然还有一些其他的网络内容包含的范围非常大,只是没有接触到。现在的信息非常的发达,我们接触到的信息也非常的多,那么我们的信息是怎么完成网络共享,资源共享的尼?

我们第一时间就是想到,浏览器,微信,QQ,这都是非常常见的网络信息传递和获取的方式,那是怎么传递的尼?网络又是一个什么样的?这里将引入网络模型的概念,常见的七层 OSI 模型与四层 TCP/IP 模型诠释了网络的连接与资源传递,当一个用户或一台主机向另一个用户或一台主机获取或是发送资源,中间的连接方式就封装在模型概念上传输或连接。

OSI 模型

网络

1983年,国际标准化组织(International Organization for Standardization,ISO)发布了著名的 ISO/IEC7498 标准,也就是开放式系统互连参考模型(Open System Interconnection Reference Model,OSI)。这个标准定义了网络的七层框架,试图使计算机在整个世界范围内实现互联。在 OSI 中,网络体系结构被分成以下7层。如下图

  1. 物理层:定义了通信设备的传输规范,规定了激活、维持和关闭通信节点之间的机械特性、电气特性和功能特性等。此层为上层协议提供了一个传输数据的物理媒介。简单的描述是将数据转换为可通过物理介质传送的电子信号 相当于快递公司中的搬运工人。
  2. 数据链路层:定了数据封装以及传送的方式。这个层次的数据单位称为帧。数据链路层包括两个重要的子层:逻辑链路控制层(Logic Link Control,LLC)和介质访问控制层(Media Access Control,MAC)。LLC 用来对节点间的通信链路进行初始化,并防止链路中断,确保系统的可靠通信。而MAC则用来检测包含在数据帧中的地址信息。这里的地址是链路地址或物理地址,在设备制造的时候设置的。网络上的两种设备不能够包含相同的物理地址,否则会造成网络信息传送失败。简单的描述是决定访问网络介质的方式。在此层将数据分帧,并处理流控制。本层指定拓扑结构并提供硬件寻址,相当于快递公司中的装拆箱工人。
  3. 网络层:定义了数据的寻址和路由方式。这一层负责选择子网间的数据路由,并实现网络互连等功能。简单的描述是使用权数据路由经过大型网络,相当于快递公司中的排序工人按指定的省市地区分类。
  4. 传输层:为数据提供端到端传输。这是比网络层更高的层次,是主机到主机的层次。传输层将对上层的数据进行分段并进行端到端传输。另外,还提供差错控制和流量控制问题。简单的描述是提供终端到终端的可靠连接,相当于公司中跑快递点邮寄东西的人员。
  5. 会话层:用来为通信的双方制定通信方式,包括建立和拆除会话。另外,此层将会在数据中插入校验点来实现数据同步。简单的描述允许用户使用简单易记的名称建立连接 相当于公司中收寄快递、包裹与拆包裹的前台服务人员。
  6. 表示层:为不同的用户提供数据和信息的转换。同时还提供解压缩和加解密服务。这一层保证了两个主机的信息可以互相理解。简单的描述是协商数据交换格式 相当公司中替领导拿或寄快递的助理。
  7. 应用层:控制着用户绝大多数对于网络应用程序的访问,提供了访问网络服务的接口。简单的描述是用户的应用程序和网络之间的接口。

img

数据信息的实际流程使用的是实线的箭头标记的,而层次的关系则使用的是虚线来标记的。在 OSI 的七层模型中,数据访问只会在上下的两层之间进行。

这是一个通用的网络系统模型,并不是一个协议定义。所以实际上 OSI 模型从来没有被真正实现过。但是,由于其模型的广泛指导性,现在的网络协议都已经纳入了 OSI 模型的范围之内。在其模型中,从下至上层次依次增加,其中物理层为第一层,数据链路层为第二层,以此类推,应用层为第七层。在称呼 OSI 协议中的模型的时候,可以直接使用本来的名字,也可以直接使用数字层次。

TCP/IP 模型

实际上在 OSI 模型出现之前,就已经有了 TCP/IP 的研究和实现。时间 早可以追溯到20世纪70年代,为互联网的早的通信协议。TCP 为传输层的协议,而 IP 则为网络层的协议。两个层次中有代表性的协议组合代表了一系列的协议族,还包括有 ARP、ICMP 和 UDP 协议等。由于 TCP/IP 协议出现的比 OSI 早,所以并不符合 OSI 模型,对应关系如下

img

图的左边为 OSI 模型,而右边为 TCP/IP 模型。从图中可以看到,TCP/IP 模型并不关心 IP 层以下的组成,而是将数据输出统一成了网络接口层。这样,IP 层只需要将数据发往网络接口层就可以了,而不需要关心下层具体的操作。而在 OSI 模型中,则将这些功能分成了数据链路层和物理层,而且还进行了进一步的划分。在传输层和网络层大部分还是一致的。而对于 OSI 中的上面三层,则在 TCP/IP 模型中将其合并成了应用层。

在现在的 Internet 中,主要采用的都是 TCP/IP 协议。这已经成为互联网上通信的事实标准。现在,TCP/IP协议已经可以运行在各种信道和底层协议之上。在 TCP/IP 模型中, 主要的两个协议 TCP/IP 分别属于传输层和互联网层。在互联网层中,标志主机的方法是使用IP地址,如192.168.0.1就是一个内网主机的 IP 地址。通过对 IP 地址的类别划分,可以将整个 Internet 网络划分成不同的子网。而在传输层中,标志一个应用的方法是通过端口号来标志的,这些不同的端口号则表示不同的应用。例如80端口一般来说是 HTTP 协议,而23号端口则是 Telnet 协议等。这样,在 TCP/IP 模型中,标志一个主机上的应用则可以通过地址-端口对来表示,后面也会详细的说明协议具体情况

协议

协议也叫网络协议,网络协议是通信计算机双方必须共同遵从的一组约定。如怎么样建立连接、怎么样互相识别等。只有遵守这个约定,计算机之间才能相互通信交流。它的三要素是:语法、语义、时序。为了使数据在网络上从源到达目的,网络通信的参与方必须遵循相同的规则,这套规则称为协议(protocol),它 终体现为在网络上传输的数据包的格式。协议往往分成几个层次进行定义,分层定义是为了使某一层协议的改变不影响其他层次的协议。

遵循我们的 OSI 模型作为参考,常用到的协议有:网络层协议 IP、传输层协议 TCP 和 UDP、应用层协议 HTTP等常用协议。下面简述一下协议的基本的概念和理解

  1. IP 协议

    IP 是整个 TCP/IP 协议族的核心,也是构成互联网的基础。IP 位于 TCP/IP 模型的网络层(相当于OSI模型的网络层),对上可 送传输层各种协议的信息,例如 TCP、UDP 等;对下可将 IP 信息包放到链路层,通过以太网、令牌环网络等各种技术来 传送。 为了能适应异构网络,IP 强调适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。IP 不保证分组的交付时限 和可靠性,所传送分组有可能出现丢失、重复、延迟或乱序等问题;

    目前的 IP 版本有4和6,如 IPv4 和 IPv6,目前 流行的就是 IPv4,有十进制和二进制两种表示方法。分别是:

    1. 点分四组十进制。每一组范围是[0~255],如:255.255.255.255
    2. 二进制,如:11111111 11111111 11111111 11111111

    IPv6 地址长度是128位,由8块(或8个字段)组成,每一块都包含四个16进制数,每块由冒号分隔。

    有以下特点:

    1. 一个块中前导的0不必书写。
    2. 全0的块可以省略,并用符号::代替。
    3. IPv6 可以兼容 IPv4 地址,即可以用 IPv6 格式表示 IPv4 地址。表示方式为:IPv6 块值为ffff,其后面紧跟“点分四组”的 格式。如:::ffff:10.0.0.1可以代表 IPv4:10.0.0.1
    4. IPv6 的低32位通常采用点分四组的表示法。:::ffff:10.0.0.1

    IP 地址是用来识别网络上的设备,因此,IP 地址是由网络地址与主机地址两部分所组成

    网络地址可用来识别设备所在的网络,网络地址位于 IP 地址的前段。当组织或企业申请 IP 地址时,所获得的并非 IP 地址 而是取得一个唯一的、能够识别的网络地址。同一网络上的所有设备,都有相同的网络地址。IP 路由的功能是根据 IP 地址中的网络地址,决定要将 IP 信息包送至所指明的那个网络

    主机地址位于 IP 地址的后段,可用来识别网络上设备。同一网络上的设备都会有相同的网络地址,而各设备之间则是以主机地址来区别。

    以下单只 IP 协议的地址结构图

    image-20220203174319488

  2. A 类 IP 地址

一个 A 类 IP 地址由1字节的网络地址和3字节主机地址组成,网络地址的 高位必须是“0”,地址范围1.0.0.1-126.255.255.254

二进制表示为:00000001 00000000 00000000 00000001 - 01111110 11111111 11111111 11111110

可用的 A 类网络有126个,每个网络能容纳1677214个主机

  1. B 类 IP 地址

一个 B 类 IP 地址由2个字节的网络地址和2个字节的主机地址组成,网络地址的 高位必须是“10”,地址范围128.1.0.1-191.255.255.254

二进制表示为:10000000 00000001 00000000 00000001 - 10111111 11111111 11111111 11111110

可用的 B 类网络有16384个,每个网络能容纳65534主机

  1. C 类 IP 地址

一个 C 类 IP 地址由3字节的网络地址和1字节的主机地址组成,网络地址的 高位必须是“110” 范围192.0.1.1-223.255.255.254

二进制表示为: 11000000 00000000 00000001 00000001 - 11011111 11111111 11111110 11111110

C 类网络可达2097152个,每个网络能容纳254个主机

  1. D 类 IP 地址用于多点广播

D 类 IP 地址第一个字节以“1110”开始,它是一个专门保留的地址。

它并不指向特定的网络,目前这一类地址被用在多点广播(Multicast)中多点广播地址用来一次寻址一组计算机

地址范围224.0.0.1-239.255.255.254

  1. E 类 IP 地址以“1111”开始,为将来使用保留

E 类地址保留,仅作实验和开发用

  1. 私有 ip

在这么多网络IP中,国际规定有一部分IP地址是用于我们的局域网使用,也就是属于私网IP,不在公网中使用的,它们的范围是:

10.0.0.0 ~ 10.255.255.255
176.16.0.0 ~ 176.16.255.255
192.168.0.0 ~ 192.168.255.255
  1. 注意

IP地址127.0.0.1~127.255.255.255用于回路测试,如:127.0.0.1可以代表本机IP地址,用 http://127.0.0.1 就可以测试本机中配置的Web服务器。

  1. 子网掩码

要想理解什么是子网掩码,就不能不了解 IP 地址的构成。互联网是由许多小型网络构成的,每个网络上都有许多主机, 这样便构成了一个有层次的结构。IP 地址在设计时就考虑到地址分配的层次特点,将每个IP地址都分割成网络号和主机号两部分,以便于 IP 地址的寻址操作。

IP 地址的网络号和主机号各是多少位呢?

如果不指定,就不知道哪些位是网络号、哪些是主机号,这就需要通过子网掩码来实现。

子网掩码不能单独存在,它必须结合 IP 地址一起使用。

子网掩码只有一个作用,就是将某个 IP 地址划分成网络地址和主机地址两部分子网掩码的设定必须遵循一定的 规则

与 IP 地址相同,子网掩码的长度也是32位,

  • 左边是网络位,用二进制数字“1”表示;
  • 右边是主机位,用二进制数字“0”表示。

假设 IP 地址为“192.168.1.1”

子网掩码为“255.255.255.0”。

其中,“1”有24个,代表与此相对应的 IP 地址左边24位是网络号;
“0”有8个,代表与此相对应的 IP 地址右边8位是主机号。
这样,子网掩码就确定了一个 IP 地址的32位二进制数字中哪些是网络号、哪些是主机号。
这对于采用 TCP/IP 协议的网络来说非常重要,只有通过子网掩码,才能表明一台主机所在的子网与其他子网的关系,使网络正常工作。

常用的两种子网掩码

子网掩码是“255.255.255.0”的网络:

最后面一个数字可以在0~255范围内任意变化,因此可以提供256个 IP 地址。

但是实际可用的 IP 地址数量是256-2,即254个,因为主机号不能全是“0”或全是“1”。

主机号全为0,表示网络号主机号全为1,表示网络广播注意:

如果将子网掩码设置过大,也就是说子网范围扩大,那么,根据子网寻径规则,很可能发往和本地主机不在同一子网内的目标主机的数据,会因为错误的判断而认为目标主机是在同一子网内,那么,数据包将在本子网内循环,直到超时并抛弃,使数据不能正确到达目标主机,导致网络传输错误;如果将子网掩码设置得过小,那么就会将本来属于同一子网内的机器之间的通信当做是跨子网传输,数据包都交给缺省网关处理,这样势必增加缺省网关(文章下方有解释)的负担,造成网络效率下降。因此,子网掩码应该根据网络的规模进行设置。如果一个网络的规模不超过254台电脑,采用“255.255.255.0”作为子网掩码就可以了,现在大多数局域网都不会超过这个数字,因此“255.255.255.0”是常用的 IP 地址子网掩码;假如在一所大学具有1500多台电脑,这种规模的局域网可以使用“255.255.0.0”。

  1. TCP 与 UDP 协议

在 TCP/IP 网络体系结构中,TCP(传输控制协议)、UDP(用户数据报协议)是传输层重要的两种协议,为上层用户提供级别的通信可靠性。

传输控制协议(TCP):TCP(传输控制协议)定义了两台计算机之间进行可靠的传输而交换的数据和确认信息的格式 以及计算机为了确保数据的正确到达而采取的措施。协议规定了TCP软件怎样识别给定计算机上的多个目的进程如何对分组、重复这类差错进行恢复。协议还规定了两台计算机如何初始化一个 TCP 数据流传输以及如何结束这一传输。TCP 最大的特点就是提供的是面向连接、可靠的字节流服务。

用户数据报协议(UDP):UDP(用户数据报协议)是一个简单的面向数据报的传输层协议。提供的是非面向连接的、 不可靠的数据流传输。UDP 不提供可靠性,也不提供报文到达确认、排序以及流量控制等功能。它只是把应用程序传给 IP 层的数据发送出去,但是并不能保证它们能到达目的地。因此报文可能会丢失、重复以及乱序等。但由于 UDP 在传输数据报文前不用在客户和服务器之间建立一个连接,且没有超时重发等机制,故而传输速度很快。

TCP 和 UDP 最大的区别就是:TCP 是面向连接的,UDP 是无连接的。TCP 协议和 UDP 协议各有所长、各有所短,适用于不同要求的通信环境

面向连接的 TCP:TCP 协议是一种可靠的、一对一的、面向有连接的通信协议

“面向连接”就是在正式通信前必须要与对方建立起连接,是按照电话系统建模的。比如你给别人打电话,必须等线路接 通了、对方拿起话筒才能相互通话。客户端与服务端在使用 TCP 传输协议时要先建立一个“通道”,在传输完毕之后又要关闭这“通道”,前者可以被形象地成为“三次握手”,而后者则可以被称为“四次挥手”。

通道的建立——三次握手:

  1. 在建立通道时,客户端首先要向服务端发送一个 SYN 同步信号。
  2. 服务端在接收到这个信号之后会向客户端发出 SYN 同步信号和 ACK 确认信号。
  3. 当服务端的 ACK 和 SYN 到达客户端后,客户端与服务端之间的这个“通道”就会被建立起来。

img

通道的关闭——四次挥手:

  1. 在数据传输完毕之后,客户端会向服务端发出一个 FIN 终止信号。

  2. 服务端在收到这个信号之后会向客户端发出一个 ACK 确认信号。

  3. 如果服务端此后也没有数据发给客户端时服务端会向客户端发送一个 FIN 终止信号。

  4. 客户端收到这个信号之后会回复一个确认信号,在服务端接收到这个信号后,服务端与客户端的通道也就关闭。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Wfx5gVU-1644045923261)(https://s2.loli.net/2022/02/02/Lw4apNAd81cYlM3.jpg)]

TCP 协议能为应用程序提供可靠的通 信连接,使一台计算机发出的字节流无差错地发往网络上的其他计算机,对可靠 性要求高的数据通信系统往往使用 TCP 协议传输数据。

无连接的 UDP 协议:UDP 协议是一种不可靠的、面向无连接、可以实现多对一、一对多和一对一连接的通信协议

“无连接”就是在正式通信前不必与对方先建立连接,不管对方状态就直接发送。与手机短信非常相似:你在发短信的时 候,只需要输入对方手机号就 OK 了。UDP 在传输数据前既不需要建立通道,在数据传输完毕后也不需要将通道关闭。只要 客户端给服务端发送一个请求,服务端就会一次性地把所有数据发送完毕。UDP 在传输数据时不会对数据的完整性进行验 证,在数据丢失或数据出错时也不会要求重新传输,因此也节省了很多用于验证数据包的时间,所以以 UDP 建立的连接的延迟会比以 TCP 建立的连接的延迟更低。UDP 不会根据当前的网络情况来控制数据的发送速度,因此无论网络情况是好是坏,服务端都会以恒定的速率发送数据。虽然这样有时会造成数据的丢失与损坏,但是这一点对于一些实时应用来说是十分重要的。基于以上三点,UDP 在数据传输方面速度更快,延迟更低,实时性更好,因此被广泛地用于通信领域和视频网站当中

UDP 适用于一次只传送少量数据、对可靠性要求不高的应用环境。比如,我们经常使用 “ping” 命令来测试两台主机之间 TCP/IP 通信是否正常,其实 “ping” 命令的原理就是向对方主机发送 ICMP 数据包,然后对方主机确认收到数据包,如果数据 包到达的消息及时反馈回来,那么网络就是通的。例如,在默认状态下,一次 “ping” 操作发送4个数据包。大家可以看到,发送的数据包数量是4包,收到的也是4包(因为对方主机收到后会发回一个确认收到的数据包)。这充分说明了 UDP 协议是面向非连接的协议,没有建立连接的过程。正因为 UDP 协议没有连接的过程,所以它的通信效率高;但也正因为如 此,它的可靠性不如 TCP 协议高。QQ 就使用 UDP 发消息,因此有时会出现收不到消息的情况。

  1. HTTP 协议

HTTP 是一个简单的请求-响应协议,它通常运行在 TCP 之上。它指定了客户端可能发送给服务器什么样的消息以及得到 什么样的响应。请求和响应消息的头以 ASCII 码形式给出;而消息内容则具有一个类似 MIME 的格式。这个简单模型是早期 Web 成功的有功之臣,因为它使得开发和部署是那么的直截了当。

HTTP 是基于客户/服务器模式,且面向连接的。典型的 HTTP 事务处理有如下的过程

  1. 客户与服务器建立连接;
  2. 客户向服务器提出请求;
  3. 服务器接受请求,并根据请求返回相应的文件作为应答;
  4. 客户与服务器关闭连接。

客户与服务器之间的 HTTP 连接是一种一次性连接,它限制每次连接只处理一个请求,当服务器返回本次请求的应答后便立即关闭连接,下次请求再重新建立连接。这种一次性连接主要考虑到 WWW 服务器面向的是 Internet 中成干上万个用户, 且只能提供有限个连接,故服务器不会让一个连接处于等待状态,及时地释放连接可以大大提高服务器的执行效率。

HTTP 是一种无状态协议,即服务器不保留与客户交易时的任何状态。这就大大减轻了服务器记忆负担,从而保持较快的响应速度。HTTP 是一种面向对象的协议。允许传送任意类型的数据对象。它通过数据类型和长度来标识所传送的数据内容和大小,并允许对数据进行压缩传送。当用户在一个 HTML 文档中定义了一个超文本链后,浏览器将通过 TCP/IP 协议与指定的服务器建立连接。

从技术上讲是客户在一个特定的 TCP 端口(端口号一般为80)上打开一个套接字。如果服务器一直在这个周知的端口上倾听连接,则该连接便会建立起来。然后客户通过该连接发送一个包含请求方法的请求块。

HTTP 规范定义了9种请求方法,每种请求方法规定了客户和服务器之间不同的信息交换方式,常用的请求方法是

GET 和 POST。服务器将根据客户请求完成相应操作,并以应答块形式返回给客户, 后关闭连接。

端口

什么是端口?如下图:

img

端口就是:是设备与外界通讯交流的出口那么 TCP/IP 协议中的端口指的是什么呢?

端口就好一个房子的门,是出入这间房子的必经之路。如果一个进程需要收发网络数据,那么就需要有这样的端口,在 Linux 系统中,端口可以有65536(2的16次方)个之多!既然有这么多,操作系统为了统一管理,所以进行了编号,这就是端口号

端口是通过端口号来标记的,端口号只有整数,范围是从0到65535

img

端口号不是随意使用的,而是按照一定的规定进行分配。

端口的分类标准有好几种,我们这里不做详细讲解,只介绍一下知名端口和动态端口知名端口是众所周知的端口号,范围从0到1023

80 端口分配给 HTTP 服务
21 端口分配给 FTP 服务

可以理解为,一些常用的功能使用的号码是估计的,好比 电话号码110、10086、10010一样,一般情况下,如果一个程序需要使用知名端口的需要有 root 权限

动态端口的范围是从1024到65535,之所以称为动态端口,是因为它一般不固定分配某种服务,而是动态分配。动态分配是指当一个系统进程或应用程序进程需要网络通信时,它向主机申请一个端口,主机从可用的端口号中分配一个供它使用。当这个进程关闭时,同时也就释放了所占用的端口号。

# 用 "netstat -an" 查看端口状态

端口有什么用呢 ? 我们知道,一台拥有IP地址的主机可以提供许多服务,比如 HTTP(万维网服务)、FTP(文件传输)、SMTP(电子邮件)等,这些服务完全可以通过1个 IP 地址来实现。那么,主机是怎样区分不同的网络服务呢?显然不能只靠 IP 地址,因为 IP 地址与网络服务的关系是一对多的关系。实际上是通过“IP地址+端口号”来区分不同的服务的。 需要注意的是,端口并不是一一对应的。比如你的电脑作为客户机访问一台 WWW 服务器时,WWW 服务器使用“80”端口与你的电脑通信,但你的电脑则可能使用“3457”这样的端口。

BS/CS 结构

C/S 是 Client/Server 的缩写。服务器通常采用高性能的PC、工作站或小型机,并采用大型数据库系统,如Oracle、Sybase、Informix 或 SQL Server。客户端需要安装专用的客户端软件。

B/S 是 Browser/Server 的缩写,客户机上只要安装一个浏览器(Browser),如 Netscape Navigator 或 Internet Explorer,服务器安装 Oracle、Sybase、Informix 或 SQL Server 等数据库。在这种结构下,用户界面完全通过

WWW 浏览器实现,一部分事务逻辑在前端实现,但是主要事务逻辑在服务器端实现。浏览器通过 Web Server 同数据库进行数据交互。

系统开发中 C/S 结构(Client/Server)中 Client(客户端)往往可以由 B/S 结构(Browser/Server结构)的 Browser(浏览器)及其载体承担,C/S 结构的 Web 应用与 B/S 结构(Browser/Server结构)具有紧密联系。大系统

和复杂系统中,C/S 结构和 B/S 结构的嵌套也很普遍。 原来的 Client/Server 结构转变成 Browser/Server 结构后,客户机的压力大大减轻,负荷被均衡地分配给了服务器。由于这种结构不再需要专用的客户端软件,因此也使技术维护人员从繁重的安装、配置和升级等维护工作中解脱了出来,可以把主要精力放在服务器程序的更新工作上。同时,使用 Web 浏览器作为客户端软件,界面友好,新开发的系统也不需要用户每次都从头学习。

Socket

我们了解了协议的基本概念,协议之间的关系,与层级之间的关联,完成了对网络的一个理解,当我们在 QQ、微信给别人发送消息时,就是通过这些层级数据传输的协议去传输我们的内容,或是我们浏览网页,打开一个百度页面,都是基于协议传输的数据显示请求的页面,那我们如何去实现这些?不可能每一步都需要去写,从 底层开始写起,那就太麻烦了,由此引出一个概念 Socket,需要一一解答的问题,Socket 在哪里?Socket 是什么?目的是为了做什么?先看下图

img

Socket 也叫套接字(Socket)随着 TCP/IP 协议的使用,也越来越多地被使用在网络应用程序的构建中。实际上,Socket 编程也已经成为网络中传送和接收数据的首选方法。套接字早是由伯克利在 BSD 中推出的一种进程间通信方案和网络互联的基本机制。现在,已经有多种相关的套接字实现,但是大部分还是遵循着初的设计要求。套接字相当于应用程序访问下层网络服务的接口。使用套接字,可以使得不同的主机之间进行通信,从而实现数据交换。

Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面,对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。

Socket

本地的进程间通信(IPC)有很多种方式,例如

  • 队列
  • 同步(互斥锁、条件变量等)

以上通信方式都是在一台机器上不同进程之间的通信方式,那么问题来了

网络中进程之间如何通信

首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!

在本地可以通过进程 PID 来唯一标识一个进程,但是在网络中这是行不通的。

其实 TCP/IP 协议族已经帮我们解决了这个问题,网络层的“ip 地址”可以唯一标识网络中的主机,而传输层的“协议 + 端口”可以唯一标识主机中的应用程序(进程)。

这样利用 ip 地址,协议,端口就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互

socket (简称 套接字) 是进程间通信的一种方式,它与其他进程间通信的一个主要不同是:它能实现不同主机间的进程间通信,我们网络上各种各样的服务大多都是基于 Socket 来完成通信的,例如我们每天浏览网页、QQ 聊天、收发 email 等等

Socket编程

那我们如何实现尼?

在 Python 中 使用 socket 模块的函数 socket 就可以完成:

socket.socket(AddressFamily, Type)

说明

套接字最初是为同一主机上的应用程序所创建,使得主机上运行的一个程序(又名一个进程)与另一个运行的程序进行通信。这就是所谓的进程间通信(Inter Process Communication,IPC)有两种类型的套接字:基于文件的和面向网络的。

'''
	函数 socket.socket 创建一个 socket,返回该 socket 的描述符,该函数带有两个参数:
	- Address Family:可以选择 AF_INET(用于 Internet 进程间通信) 或者 AF_UNIX(用于同一台机器进程间通信),实际工作中常用AF_INET
	- Type:套接字类型,可以是 SOCK_STREAM(流式套接字,主要用于 TCP 协议)或者 SOCK_DGRAM(数据报套接字,主要用于 UDP 协议)
'''

那我们来创建一个基于 tcp socket 的套接字

import socket
# 获得socket对象 AF_INET表示基于网络的套接字
# SOCK_STREAM表示的是TCP协议
socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print ('Socket Created')

创建一个udp socket(udp 套接字)

import socket
# 获得socket对象 AF_INET表示基于网络的套接字
# SOCK_DGRAM表示的是UDP协议
socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print ('Socket Created')

以上就是基于 socket 模块实现创建最简单的对象模式,那我们来基于 socket 实现 TCP/UDP 的实现

UDP 客户端

# -*- coding: UTF-8 -*-
# 文件名:client.py
# 导入 socket 模块
from socket import *
# 创建套接字
client_socket = socket(AF_INET, SOCK_DGRAM)
# 准备接收方地址
server_host_post = ('127.0.0.1', 8080)
# 发送数据时,python3需要将字符串转成byte
# encode(‘utf-8’)# 用utf-8对数据进行编码,获得bytes类型对象
client_data = input("请输入:").encode('utf8')
# 将拿到转为bytes类型data数据对象,通过socket中的sendto方法,将数据发送到ip+协议+端口对应的地址
client_socket.sendto(client_data, server_host_post)
# 如果发送成功我们就提示一句发送成功
print('发送成功')
# 关闭客户端
client_socket.close()

我们看到运行结果是输入我们想要的内容是能运行成功的,但是我们内容去了那里了,这里我们就需要详细的了解协议的性质,是无连接的,既不能保证数据的完整性,也不能保证数据发送,所以就无法产生稳定的数据传输,但

是也是可以接收到消息的哦,这里就需要我们的服务器,任何客户端,必须得链接到其他的服务器,进行交互响应,才能完成动态的信息交互,那我们怎么实现呢?

SocketServer

我们在上一章讲到简单的 Socket 的编程实现操作,那我们现在来完成基于服务器与客户端的协议之间交互信息

UDP

UDP — 用户数据报协议,是一个无连接的简单的面向数据报的运输层协议。UDP 不提供可靠性,它只是把应用程序传给 IP 层的数据报发送出去,但是并不能保证它们能到达目的地。由于 UDP 在传输数据报前不用在客户和服务器之间建立一个连接,且没有超时重发等机制,故而传输速度很快。

UDP 是一种面向无连接的协议,每个数据报都是一个独立的信息,包括完整的源地址或目的地址,它在网络上以任何可能的路径传往目的地,因此能否到达目的地,到达目的地的时间以及内容的正确性都是不能被保证的。

UDP 特点

  • UDP 是面向无连接的通讯协议,UDP 数据包括目的端口号和源端口号信息,由于通讯不需要连接,所以可以实现广播发送
  • UDP 传输数据时有大小限制,每个被传输的数据报必须限定在 64 KB 之内。
  • UDP 是一个不可靠的协议,发送方所发送的数据报并不一定以相同的次序到达接收方。

【适用情况】

UDP 是面向消息的协议,通信时不需要建立连接,数据的传输自然是不可靠的,UDP 一般用于多点通信和实时的数据业务,比如

  • 语音广播
  • 视频
  • QQ
  • TFTP(简单文件传送)
  • SNMP(简单网络管理协议)
  • RIP(路由信息协议,如报告股票市场,航空信息)
  • DNS(域名解释)

相比较于 TCP 注重速度流畅

UDP 操作简单,而且仅需要较少的监护,因此通常用于局域网高可靠性的分散系统中 client/server 应用程序。例如视频会议系统,并不要求音频视频数据绝对的正确,只要保证连贯性就可以了,这种情况下显然使用 UDP 会更合理一些。

那如何实现尼?

我们创建一个 udp 客户端程序的流程是简单的,具体步骤如下:

'''
	1.创建客户端套接字
	2.发送/接收数据
	3.关闭套接字
'''

image-20220202213926873

打开我们的开发工具编译器

客户端如下代码:发送数据

# -*- coding: UTF-8 -*-
# 文件名:client.py
# 导入 socket 模块
from socket import *
# 创建套接字
client_socket = socket(AF_INET, SOCK_DGRAM)
# 准备接收方地址
server_host_post = ('127.0.0.1', 8080)
# 发送数据时,python3需要将字符串转成byte
# encode(‘utf-8’)# 用utf-8对数据进行编码,获得bytes类型对象
client_data = input("请输入:").encode('utf8')
# 将拿到转为bytes类型data数据对象,通过socket中的sendto方法,将数据发送到ip+协议+端口对应的地址
client_socket.sendto(client_data, server_host_post)
# 如果发送成功我们就提示一句发送成功
print('发送成功')
# 关闭客户端
client_socket.close()

客户端的结构

  1. 使用 socket(),生成套接字描述符
  2. 通过 host_post 结构设置服务器地址和监听端口
  3. 向服务器发送数据,sendto()
  4. 关闭套接字,close()

运行看看

image-20220202214436706

通过客户端发送成功,我们指定了端口,但是我们没有与服务器建立连接,没有确定8080端口的连接对象,可以将 ip 地址与端口随意需改符合要求的范围,都可以将数据正常发送
此时数据发送,这里就体现 UDP 数据的特性,面向无连接的通讯协议这里我们接受数据该如何实现尼?往下看服务器端如下代码:接收数据

# -*- coding: UTF-8 -*-
# 文件名:server.py
# 导入 socket 模块
from socket import *
# 创建套接字对象
socket = socket(AF_INET, SOCK_DGRAM)
# 准备接收地址
host_post = ('127.0.0.1', 8080)
# 绑定地址、端口,类型为元组
socket.bind(host_post)
# 这里接受到的数据socket.recvfrom(1024)是一个元组形式
# 那这里可以看看具体的信息
data = socket.recvfrom(1024)
print(data)
print(data[0].decode('utf8'))
# 关闭连接
socket.close()

服务器端的结构

  1. 使用函数 socket(),生成套接字描述符;
  2. 通过 host_post 结构设置服务器地址和监听端口;
  3. 使用 bind() 函数绑定监听端口,将套接字文件描述符和地址类型变量(host_post)进行绑定;
  4. 接收客户端的数据,使用 recvfrom() 函数接收客户端的网络数据;
  5. 关闭套接字,使用 close() 函数释放资源;

那让我们来看一下运行的效果与结果

image-20220202214909072

先运行我们的服务器,再运行我们的客户端,输入我们需要给到服务器的数据,在客户端,观察发现向服务器发送消息的时候没有给客户端绑定端口?原来操作系统在此 做了些隐蔽的事情,当 socket 首先向服务器发消息时客户端自动选折 IP 和一个 PORT 与该 socket 关联了起来。

那客户端与服务器之间的交互,能不能绑定端口,更多是多人交互尼,来我们继续往下走

echo 服务的应用 ,echo 服务是一种非常有用的用于调试和检测的工具。该协议接收到什么原样发回,类似于日常生活中的“回声”,即存在回显
我们修改服务器与客户端的代码

客户端代码如下:

# -*- coding: UTF-8 -*-
# 文件名:client.py
import socket
# 创建套接字
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
    # 准备接收方地址
    server_host_post = ('127.0.0.1', 12345)
    # 发送数据时,python3需要将字符串转成byte
    # encode(‘utf-8’)# 用utf-8对数据进行编码,获得bytes类型对象
    client_data = input("请输入:").encode('utf8')
    # 将拿到转为bytes类型data数据对象,通过socket中的sendto方法,将数据发送到ip+协议+端口对应的地址
    client_socket.sendto(client_data, server_host_post)
    # 这里接受到的数据socket.recvfrom(1024)是一个元组形式,接受服务器返回来的信息
    print('返回数据是:', client_socket.recvfrom(1024)[0].decode('utf8'))
# 关闭客户端
client_socket.close()

服务器代码如下:

# -*- coding: UTF-8 -*-
# 文件名:client.py
import socket
# 创建套接字
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
    # 准备接收方地址
    server_host_post = ('127.0.0.1', 12345)
    # 发送数据时,python3需要将字符串转成byte
    # encode(‘utf-8’)# 用utf-8对数据进行编码,获得bytes类型对象
    client_data = input("请输入:").encode('utf8')
    # 将拿到转为bytes类型data数据对象,通过socket中的sendto方法,将数据发送到ip+协议+端口对应的地址
    client_socket.sendto(client_data, server_host_post)
    # 这里接受到的数据socket.recvfrom(1024)是一个元组形式,接受服务器返回来的信息
    print('返回数据是:', client_socket.recvfrom(1024)[0].decode('utf8'))
# 关闭客户端
client_socket.close()

再次来看运行结果

image-20220202215305977

没有给客户端指定唯一的端口与 ip 地址,而是通过服务器端口进行传输返回数据,此时如果我在加入新的客户端,是否就可以完成多人聊天室的功能,UDP 的广播形式,再看,我将复制一个客户端文件,命名为 client1,现在将使用两个客户端对服务器进行发送消息

image-20220202215404326

udp是TCP/IP协议族中的一种协议能够完成不同机器上的程序间的数据通信

  • udp的服务器和客户端的区分:往往是通过请求服务提供服务来进行区分
  • 请求服务的一方称为:客户端
  • 提供服务的一方称为:服务器

一般情况下,服务器端,需要绑定端口,目的是为了让其他的客户端能够正确发送到此进程客户端,一般不需要绑定,而是让操作系统随机分配,这样就不会因为需要绑定的端口被占用而导致程序无法运行的情况

TFTP

TFTP(Trivial File Transfer Protocol,简单文件传输协议)是 TCP/IP 协议族中的一个用来在客户端与服务器之间进行简单文件传输的协议

特点:

  • 简单
  • 占用资源小
  • 适合传递小文件
  • 适合在局域网进行传递
  • 端口号为69
  • 基于UDP实现

TFTP 服务器默认监听69号端口

当客户端发送“下载”请求(即读请求)时,需要向服务器的69端口发送

服务器若批准此请求,则使用一个新的、临时的端口进行数据传输

image-20220202215750011

当服务器找到需要现在的文件后,会立刻打开文件,把文件中的数据通过 TFTP 协议发送给客户端

如果文件的总大小较大(比如3M),那么服务器分多次发送,每次会从文件中读取512个字节的数据发送过来

因为发送的次数有可能会很多,所以为了让客户端对接收到的数据进行排序,在服务器发送那512个字节数据的时候,会多发2个字节的数据,用来存放序号,并且放在512个字节数据的前面,序号是从1开始的

因为需要从服务器上下载文件时,文件可能不存在,那么此时服务器就会发送一个错误的信息过来,为了区分服务发送的是文件内容还是错误的提示信息,又用了2个字节来表示这个数据包的功能(称为操作码),并且在序号的前面

操作码功能
1读请求,即下载
2写请求 ,即上传
3表示数据包,即 DATA
4确认码,即 ACK
5错误

因为 udp 的数据包不安全,即发送方发送是否成功不能确定,所以 TFTP 协议中规定,为了让服务器知道客户端已经接收到了刚刚发送的那个数据包,所以当客户端接收到一个数据包的时候需要向服务器进行发送确认信息,即发送收到了,这样的包成为 ACK (应答包)

为了标记数据已经发送完毕,所以规定,当客户端接收到的数据小于516(2字节操作码 + 2个字节的序号 + 512字节数据)时,就意味着服务器发送完毕了,TFTP 数据包的格式如下:

image-20220202220133537

那既然是基于 UDP 实现的代码,那我们来看是如何实现的服务器端代码:

服务器代码:

# -*- coding: UTF-8 -*-
# 文件名:TFTP_server.py
from socket import *
import struct
def download(filename, user_ip, user_port):
    socket_down = socket(AF_INET, SOCK_DGRAM)
    num = 0
    try:
        f = open(filename, 'rb')
    except:
        error_data = struct.pack('!HHHb', 5, 5, 5, num)
        socket_down.sendto(error_data, (user_ip, user_port))  # 文件不存在时发送
        exit()  # 只会退出此线程
        while True:
            read_data = f.read(512)
            send_data = struct.pack('!HH', 3, num) + read_data
            socket_down.sendto(send_data, (user_ip, user_port))  # 数据第一次发送
            if len(read_data) < 512:
                print('传输完成, 对方下载成功')
                exit()
            recv_ack = socket_down.recv(1024)  # 第二次接收
            caozuoma, ack_num = struct.unpack("!HH", recv_ack)
            #    print(caozuoma,ack_num,len(read_data))
            num += 1
            if int(caozuoma) != 4 or int(ack_num) != num - 1:
                exit()
  f.close()
s = socket(AF_INET, SOCK_DGRAM)
s.bind(('', 69))
def main():
    while 1:
        recv_data, (user_ip, user_port) = s.recvfrom(1024)  # 第一次客户连接69端口
        print(recv_data, user_ip, user_port)
        if struct.unpack('!b5sb', recv_data[-7:]) == (0, b'octet', 0):
            caozuoma = struct.unpack('!H', recv_data[:2])
            filename = recv_data[2:-7].decode('gb2312')
            if caozuoma[0] == 1:
                print('对方想下载数据', filename)
                download(filename, user_ip, user_port)
if __name__ == '__main__':
     main()

Python 中的 struct 主要是用来处理 C 结构数据的,在转化过程中,在转化过程中,主要用到了一个格式化字符串
(format strings),用来规定转化的方法和格式。比如代码中的:‘!H’

image-20220202220755141

客户端代码:

# coding=utf-8
# 文件名:tftp_client.py
import struct
from socket import *
filename = 'test.jpg'
server_ip = '127.0.0.1'
send_data = struct.pack('!H%dsb5sb' % len(filename), 1, filename.encode('gb2312'), 0,
                        'octet'.encode('gb2312'), 0)
tftp_client = socket(AF_INET, SOCK_DGRAM)
# 第一次发送, 连接服务器69端口
tftp_client.sendto(send_data, (server_ip, 69))
# 打开二进制文件,追加写如文件
f = open(filename, 'ab')
while 1:
    # 接收数据
    recv_data = tftp_client.recvfrom(1024)
    # 获取数据块编号
    caozuoma, ack_num = struct.unpack('!HH', recv_data[0][:4])
    rand_port = recv_data[1][1]  # 获取服务器的随机端口
    if int(caozuoma) == 5:
        print('服务器返回: 文件不存在...')
        break
    print(caozuoma, ack_num, rand_port, len(recv_data[0]))
    f.write(recv_data[0][4:])
    if len(recv_data[0]) < 516:
        break
    ack_data = struct.pack("!HH", 4, ack_num)
    # 回复ACK确认包
    tftp_client.sendto(ack_data, (server_ip, rand_port))

运行结果如下

image-20220202221105873

看到原本本地的数据是没有的,当然实际上我们就是从本地凭空生成或者是读取一个 TFTP 的文件,当我们通过此方式,将数据进行连接传输,那我们就能完成使用对 TFTP 协议的使用和交互数据,这里我们理解了关于基于 UDP 模式下的多用户模式的或者是高速率信息传输,但不保证数据的完整性,那我们在需要准确数据保证数据不会丢失的情况该怎么办尼?例如发送或者接受邮件,例如资料文件等传输,我们需要准确的数据信息,基于 TCP 的面向连接。

TCP

传输控制协议(TCP)是一种面向连接的、可靠的、基于字节流的传输层通信协议,也是为了在不可靠的互联网络上提供可靠的端到端字节流而专门设计的一个传输协议。
例如生活中的电话机,我想让别人能更够打通我的电话交流或者完成工作等等事务前,我还得需要做以下几件事情:

  1. 买个手机
  2. 插上手机卡
  3. 设计手机为正常接听状态(即能够响铃)
  4. 静静的等着别人拨打

image-20220202221225547

如同上面的电话机过程一样,在程序中,如果想要完成一个 tcp 服务器的功能,需要的流程如下:

  1. socket 创建一个套接字
  2. bind 绑定 ip 和 port
  3. listen 使套接字变为可以被动链接
  4. accept 等待客户端的链接
  5. recv/send 接收发送数据

由此一个很简单的 tcp 服务器如下:

# coding=utf-8
# 文件名:tcp_server.py
from socket import *
# 创建socket
# SOCK_STREAM基于TCP
tcp_Socket = socket(AF_INET, SOCK_STREAM)
# 绑定本地信息
# ip地址和端口号,ip一般不用写,表示本机的任何一个ip
host_port = ('', 12345)
tcp_Socket.bind(host_port)
# 使用socket创建的套接字默认的属性是主动的,使用listen将其变为被动的,这样就可以接收别人的链接了
tcp_Socket.listen(5)
# 如果有新的客户端来链接服务器,那么就产生一个新的套接字专门为这个客户端服务器
# newSocket用来为这个客户端服务
# tcp_Socket就可以省下来专门等待其他新客户端的链接
newSocket, clientAddr = tcp_Socket.accept()
# 接收对方发送过来的数据,最大接收1024个字节
recvData = newSocket.recv(1024)
print('接收到的数据为:', recvData.decode('utf8'))
# 发送一些数据到客户端
senData = "thank you !"
newSocket.send(senData.encode('utf8'))
# 关闭为这个客户端服务的套接字,只要关闭了,就意味着为不能再为这个客户端服务了,如果还需要服务,只能再次重新连接
newSocket.close()
# 关闭监听套接字,只要这个套接字关闭了,就意味着整个程序不能再接收任何新的客户端的连接
tcp_Socket.close()

由此我们的客户端创建好了。

客户端代码如下

# coding=utf-8
# 文件名:tcp_client.py
# 创建socket
tcp_client = socket(AF_INET, SOCK_STREAM)
# 链接服务器
host_port = ('127.0.0.1', 12345)
tcp_client.connect(host_port)
# 提示用户输入数据
sendData = input("请输入要发送的数据:")
# 发送数据给指定的客户端
tcp_client.send(sendData.encode('utf8'))
# 接收对方发送过来的数据,最大接收1024个字节
recvData = tcp_client.recv(1024)
print('接收到的数据为:', recvData.decode('utf8'))
# 关闭套接字
tcp_client.close()

我们先来看看运行后的结果

image-20220202221644761

在我们的服务器端,我们绑定 IP + 端口协议方式,将服务器的数据传输方式绑定,基于数据传输,返回指定数据连接服务器的信息,在这里面我们完成了对数据基于 tcp 的传输流程方式,当客户端需要链接服务器时,就需要使用 connect 进行链接,udp 是不需要链接的而是直接发送,但是 tcp 必须先链接,只有链接成功才能通信

tcp 注意点

  • tcp 服务器一般情况下都需要绑定,否则客户端找不到这个服务器
  • tcp 客户端一般不绑定,因为是主动链接服务器,所以只要确定好服务器的 ip、port 等信息就好,本地客户端可以随机
  • tcp 服务器中通过 listen 可以将 socket 创建出来的主动套接字变为被动的,这是做 tcp 服务器时必须要做的
  • 当一个 tcp 客户端连接服务器时,服务器端会有1个新的套接字,这个套接字用来标记这个客户端,单独为这个客户端服务
  • listen 后的套接字是被动套接字,用来接收新的客户端的链接请求的,而 accept 返回的新套接字是标记这个新客户端的
  • 关闭 listen 后的套接字意味着被动套接字关闭了,会导致新的客户端不能够链接服务器,但是之前已经链接成功的客户端正常通信。
  • 关闭 accept 返回的套接字意味着这个客户端已经服务完毕
  • 当客户端的套接字调用 close 后,服务器端会 recv 解堵塞,并且返回的长度为0,因此服务器可以通过返回数据的长度来区别客户端是否已经下线

那我们就可以来了解一下如何实现模拟QQ的聊天

模拟QQ的聊天

服务器代码

# coding=utf-8
# 文件名:qq_server.py
from socket import *
# 创建socket
tcp_server = socket(AF_INET, SOCK_STREAM)
# 绑定本地信息
host_port = ('', 12345)
tcp_server.bind(host_port)
# 使用socket创建的套接字默认的属性是主动的,使用listen将其变为被动的,这样就可以接收别人的链接了
tcp_server.listen(5)
while True:
    # 如果有新的客户端来链接服务器,那么就产生一个信心的套接字专门为这个客户端服务器
    # newSocket用来为这个客户端服务
    # tcpSerSocket就可以省下来专门等待其他新客户端的链接
    newSocket, host_port = tcp_server.accept()
    while True:
        # 接收对方发送过来的数据,最大接收1024个字节
        recvData = newSocket.recv(1024)
        # 如果接收的数据的长度为0,则意味着客户端关闭了链接
        if len(recvData) > 0:
            print('recv:', recvData)
        else:
            break
        # 发送一些数据到客户端
        sendData = input("send:")
        newSocket.send(sendData.encode('utf8'))
# 关闭为这个客户端服务的套接字,只要关闭了,就意味着为不能再为这个客户端服务了,如果还需要服务,只能再次重新连接
newSocket.close()
# 关闭监听套接字,只要这个套接字关闭了,就意味着整个程序不能再接收任何新的客户端的连接
tcp_server.close()

客户端代码如下:

# coding=utf-8
# 文件名:qq_server.py
from socket import *
# 创建socket
tcp_client = socket(AF_INET, SOCK_STREAM)
# 链接服务器
host_port = ('127.0.0.1', 12345)
tcp_client.connect(host_port)
while True:
    # 提示用户输入数据
    sendData = input("send:")
    if len(sendData) > 0:
        tcp_client.send(sendData.encode('utf8'))
    else:
        break
    # 接收对方发送过来的数据,最大接收1024个字节
    recvData = tcp_client.recv(1024)
    print('recv:', recvData.decode('uft8'))
# 关闭套接字
tcp_client.close()

好我们直接来看看运行的结果

image-20220202222406837

以上我们完成了简易的QQ通信功能,通信双方必须先建立连接才能进行数据的传输,双方都必须为该连接分配必要的系统内核资源,以管理连接的状态和连接上的传输。双方间的数据传输都可以通过这一个连接进行。完成数据交换后,双方必须断开此连接,以释放系统资源。这种连接是一对一的。

黏包

socket 黏包问题

什么是粘包:粘包指的是数据和数据之间没有明确的分界线,导致不能正确读取数据

应用程序无法直接操作硬件,应用程序想要发送数据则必须将数据交给操作系统,而操作系统需要需要同时为所有应用程序提供数据传输服务,也就意味着,操作系统不可能立马就能将应用程序的数据发送出去,就需要为应用程序提供一个缓冲区,用于临时存放数据,具体流程如下:

image-20220203161142292

发送方

当应用程序调用 send 函数时,应用程序会将数据从应用程序拷贝到操作系统缓存,再由操作系统从缓冲区读取数据并发送出去

接收方

对方计算机收到数据也是操作系统先收到,至于应用程序何时处理这些数据,操作系统并不清楚,所以同样需要将数据先存储到操作系统的缓冲区中,当应用程序调用 recv 时,实际上是从操作系统缓冲区中将数据拷贝到应用程序的过程

上述过程对于 TCP 与 UDP 都是相同的不同之处在于:

UDP:

UDP 在收发数据时是基于数据包的,即一个包一个包的发送,包与包之间有着明确的分界,到达对方操作系统缓冲区后也是一个一个独立的数据包,接收方从操作系统缓冲区中将数据包拷贝到应用程序这种方式存在的问题:

  1. 发送方发送的数据长度每个操作系统会有不同的限制,数据超过限制则无法发送
  2. 接收方接收数据时如果应用程序的提供的缓存容量小于数据包的长度将造成数据丢失,而缓冲区大小不可能无限大

这意味着 UDP 根本不会粘包,但是会丢数据,不可靠。

TCP:

当我们需要传输较大的数据,或需要保证数据完整性时,最简单的方式就是使用 TCP 协议了,与 UDP 不同的是,TCP 增加了一套校验规则来保证数据的完整性,会将超过 TCP 包最大长度的数据拆分为多个 TCP 包,并在传输数据时为每一个 TCP 数据包指定一个顺序号,接收方在收到 TCP 数据包后按照顺序将数据包进行重组,重组后的数据全都是二进制数据,且每次收到的二进制数据之间没有明显的分界基于这种工作机制 TCP 在三种情况下会发送粘包问题

  1. 当单个数据包较小时接收方可能一次性读取了多个包的数据

  2. 当整体数据较大时接收方可能一次仅读取了一个包的一部分内容

  3. 另外 TCP 协议为了提高效率,增加了一种优化机制,会将数据较小且发送间隔较短的数据合并发送,该机制也会导致发送方将两个数据包粘在一起发送

意味着:TCP 传输数据是可靠的,但是会粘包。

好的,我们先看一段代码

服务器端

# coding=utf-8
import socket
server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
ip_port=('',8080)
server_socket.bind(ip_port)
server_socket.listen(5)
conn,deer=server_socket.accept()
data1=conn.recv(1024)
data2=conn.recv(1024)
print(data1)
print(data2)
conn.close()
server_socket.close()

客户端代码

# -*- coding: utf-8 -*-
import socket
client_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
ip_port=('192.168.5.180',8080)
client_socket.connect(ip_port)
client_socket.send('helloworld'.encode('utf-8'))
client_socket.send('sb'.encode('utf-8'))
client_socket.close()

直接上我们的运行结果

img

客户端发送了两个数据包,但是在服务器端接受 data1 的时候,把这两个包的数据全部接受了,这种显现就是黏包。其实如果服务器点代码改成 recv(2) 也会造成粘(黏)包。客户端发了一段数据,服务端只收了一小部分,也产生粘包。

服务器端代码:

import socket
import time
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('', 9999))  # 0-65535:0-1024给操作系统使用
server_socket.listen(5)
conn, addr = server_socket.accept()
print('connect by ', addr)
res1 = conn.recv(2)  # 第一没有接收完整
print('第一次', res1)
time.sleep(6)
res2 = conn.recv(10)  # 第二次会接收旧数据,再收取新的
print('第二次', res2)
conn.close()
server_socket.close()

客户端代码:

import socket
import time
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('192.168.5.180', 9999))
client.send('mashibin'.encode('utf-8'))
time.sleep(5)
client.send('xiaobin'.encode('utf-8'))
client.close()

image-20220203162244684

黏包成因

所谓粘包问题主要还是因为:

  1. 接收方不知道消息之间的界限,不知道一个消息要提取多少字节的数据所造成的。(服务器端出现黏包)

  2. tcp 在发送数据少且间隔时间短的数据时,会将几条和并一起发送。(客户端出现黏包)

黏包的解决办法

目前比较合理的处理方法是:为字节流加上一个报头,告诉发送的字节流总大小,然后接收端来一个死循环接收完所有数据。用 struct 将序列化后的数据长度打包成4个字节(4个字节完全够用)。

使用 struct 模块可以用于将 Python 的值根据格式符,转换为 C 语言的结构(byte 类型),便于数据流传输。

案例:客户端传送一个文件到服务器端(基于 TCP 协议),同时要解决黏包问题。

客户端代码:

import socket
import os
import struct
client = socket.socket()
client.connect(("192.168.5.180",9999))
file_path = "new.mp4"
f = open(file_path,mode="rb")
# 在发送数据前先发送报头
size = os.path.getsize(file_path)
# 定制包头 i为4个字节,所以接收方为四个字节,这个大小并不是输入的大小,而是文件的大小
header = struct.pack('!i',size) #使用struct,直接将int转为二进制型数据传输,对方使用struct解包
client.send(header) # 发报头
# 发数据
while True:
    data = f.read(1024)
    if not data:
        break
    # 发送给服务器
    client.send(data)
print("上传完成...")
client.close()

服务器端代码:

import  socket
import  struct
server = socket.socket()
server.bind(("",9999))
server.listen()
conn,addr = server.accept()
f =  open("接收到的文件",mode="wb")
header_data = conn.recv(4)
print(struct.unpack("!i",header_data))
size = struct.unpack("!i",header_data)[0]
recv_size = 0
while recv_size < size:
    data = conn.recv(1024)
    f.write(data)
    recv_size += len(data)
print("接收完成...")
conn.close()
server.close()

执行之后的结果:

img

img

总结:客户端把数据长度封装成一个固定大小的数据,这时服务端就可以指定读取固定大小的内容,不会读取数据的内容,服务端只要根据数据长度再来接收数据内容就好了,所以客户端连续两次发数据(文件),不会粘包,因为服务<器端每次接收都只接收了本次该接收的数据。

多线程与多进程

现代操作系统比如 Mac OS X,Linux,Windows 等,都是支持“多任务”的操作系统什 么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用逛淘宝,一边在听音乐,一边在用微信聊天,这就是多任务,至少同时有 3 个任务正在运行。

image-20220203193241132

还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。现在,多核 CPU 已经非常普及了,但是,即使过去的单核 CPU,也可以执行多任务。由 于 CPU 执行代码都是顺序执行的,那么,单核 CPU 是怎么执行多任务的呢?
在这里插入图片描述

我们知道,在一台计算机中,我们可以同时打开许多软件,比如同时浏览网页、听音乐、打字等等,看似非常正常。但仔细想想,为什么计算机可以做到这么多软件同时运行呢?这就涉及到计算机中的两个重要概念:多进程和多线程了。

进程和线程都是操作系统中的重要概念,既相似,又不同

对于一般的程序,可能会包含若干进程;而每一个进程又可能包含多个同时执行的线程。进程是资源管理的 小单位,而线程则是程序执行的 小单位。

进程和线程的概念

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动 一个浏览器进程,打开记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开 一个 Word 就启动了一个 Word 进程。

进程:直观地说,进程就是正在执行的程序,为多任务操作系统中执行任务的基本单元,是包含了程序指令和相关资源的集合。在 Windows 下,可以打开任务管理器,在进程标签栏中就可以看到当前计算机中正在运行的进程,操作系统隔离各个进程可以访问的地址空间。如果进程间需要传递信息,则需要使用进程间通信或者其他方式,非常不方便而且消耗 CPU 时间片段。为了能够更好地支持信息共享和减少切换开销,从进程中演化出了线程。

线程:线程是进程的执行单元。对于大多数程序来说,可能只有一个主线程。但是,为了能够提高效率,有些程序会采用多线程,在系统中所有的线程看起来都是同时执行的。例如,现在的多线程网络下载程序中,就使用了这种线程并发的特性,程序将欲下载的文件分成多个部分,然后同时进行下载,从而加快速度。

进程和线程的对比

明确进程和线程的区别,这一点对于使用 Python 编程是非常重要的。一般的,进程是重量级的。具体包括进程映像的结构、执行细节以及进程间切换的方法。在进程中,需要处理的问题包括进程间通信、临界区管理和进程调度等。这些特性使得新生成一个进程的开销比较大。而线程刚好相反,它是轻量级的。线程之间共享许多资源,容易进行通信,生成一个线程的开销较小。但是使用线程会有死锁、数据同步和实现复杂等问题。

image-20220203193602853

并发编程解决方案

  • 启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务
  • 启动一个进程,在一个进程内启动多个线程,这样,多个线程也可以一块执行多个任务
  • 启动多个进程,每个进程再启动多个线程,这样同时执行的任务就更多了,当然这种模型更复杂,实际很少采用。

由于 Python 语言使用了全局解释器锁(Global Interpretor Lock,GIL)和队列模块,其在线程实现的复杂度上相对于其他语言来说要低得多。需要注意的是,由于 GIL 的存在,所以 Python 解释器并不是线程安全的。因为当前线程必须持有这个全局解释器锁,才可以安全地访问 Python 对象。虽然使用 GIL 使得 Python 不能够很好地利用多 CPU 优势,但是现在还没有比较好的办法来代替它,因为去掉 GIL 会带来许多问题。所以,针对 I/O 受限的程序,如网络下载类,可以使用多线程来提高程序性能。而对于 CPU 受限的程序,如科学计算类,使用多线程并不会带来效率的提升。这个时候,建议使用进程或者混合进程和线程的方法来实现。

进程的开发

在前面提到,Python 对于进程和线程处理都有很好的支持。接下来介绍在 Python 语言的标准库中相关的模块:

模块介绍模块介绍
os/sys包含基本进程管理函数subprocessPython 基本库中多进程编程相关模块,适用于与外部进程交互,调用外部进程
multiprocessing也是 Python 基本库中多进程编程模块,核心机制是 fork,重开一个进程,首先会把父进程的代码 copy 重载一遍threadingPython 基本库中多线程管理相关模块

创建进程

subprocess 模块

subprocess 早在2.4版本引入。用来生成子进程,并可以通过管道连接他们的输入/输出/错误,以及获得他们的返回值。用来替换多个旧模块和函数

运行 python 的时候,我们都是在创建并运行一个进程,(linux 中一个进程可以 fork 一个子进程,并让这个子进程 exec 另外一个程序)。在 python 中,我们通过标准库中的 subprocess 包来 fork 一个子进程,并且运行一个外部的程序。subprocess 包中定义有数个创建子进程的函数,这些函数分别以不同的方式创建子进程,所欲我们可以根据需要来从中选取一个使用。另外 subprocess 还提供了一些管理标准流(standard stream)和管道(pipe)的工具,从而在进程间使用文本通信。

通俗地说就是通过这个模块,你可以在 Python 的代码里执行操作系统级别的命令,比如“ipconfig”、“du -sh”等等。 subprocess 模块替代了一些老的模块和函数,比如:

os.system
os.spawn*

subprocess 过去版本中的call,check_call 和 check_output 已经被 run 方法取代了。run 方法为3.5版本新增。**大多数情况下,推荐使用 run 方法调用子进程,执行操作系统命令。**在更高级的使用场景,你还可以使用 Popen 接口。其实 run() 方法在底层调用的就是 Popen 接口。

run

subprocess 模块首先推荐使用的是它的 run 方法,更高级的用法可以直接使用 Popen 接口。

注意,run() 方法返回的不是我们想要的执行结果或相关信息,而是一个 CompletedProcess 类型对象。

subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None)
  • args:表示要执行的命令。必须是一个字符串,字符串参数列表。
  • stdin、stdout 和 stderr:子进程的标准输入、输出和错误。其值可以是 subprocess.PIPE、 subprocess.DEVNULL、一个已经存在的文件描述符、已经打开的文件对象或者 None。subprocess.PIPE 表示为子进程创建新的管道。subprocess.DEVNULL 表示使用 os.devnull。默认使用的是 None,表示什么都不做。另外,stderr 可以合并到 stdout 里一起输出。
  • timeout:设置命令超时时间。如果命令执行时间超时,子进程将被杀死,并弹出 TimeoutExpired 异常。
  • check:如果该参数设置为 True,并且进程退出状态码不是 0,则弹 出 CalledProcessError 异常。
  • encoding: 如果指定了该参数,则 stdin、stdout 和 stderr 可以接收字符串数据,并以该编码方式编码。否则只接收 bytes 类型的数据。
  • shell:如果该参数为 True,将通过操作系统的 shell 执行指定的命令。

run 方法调用方式,返回 CompletedProcess 实例,和直接 Popen 差不多,实现是一样的,实际也是调用 Popen,与 Popen 构造函数大致相同,看一个例子

# coding = utf-8
# 文件名: subprocess_cmd.py

# 导入模块 subprocess
import subprocess
# 这里我们使用了这么几个参数args,encoding,shell
# dir 在windows命令行中是遍历这个目录下的文件
# Linux可以使用 ls 命令
runcmd = subprocess.run(['dir', 'C:\\'], encoding='utf8', shell=True)
# 打印结果
print(runcmd)

看看运行结果

驱动器 C 中的卷没有标签。
卷的序列号是 0AA3-2019
C:\ 的目录
2018/03/16  10:16   <DIR>      EFI
2020/03/18  17:33   <DIR>      Intel
2020/07/13  11:10   <DIR>      LeakHotfix
2020/07/15  16:50   <DIR>      Program Files
2020/07/18  17:40   <DIR>      Program Files (x86)
2020/06/22  11:51   <DIR>      QMDownload
2020/03/18  17:43   <DIR>      Users
2020/07/18  16:55   <DIR>      Windows
       0 个文件        0 字节
       8 个目录 90,635,427,840 可用字节
CompletedProcess(args=['dir', 'C:\\'], returncode=0)

image-20220203195353894

这里我们看到不仅执行了命令,并且返回了一个 CompletedProcess 实例,其中returncode: 执行完子进程状态,通常返回状态为0则表明它已经运行完毕,若值为负值 “-N”,表明子进程被终。

定义一个函数,写一个基于windows cmd命令行的实例

# coding=utf-8
# 文件名:subprocess_cmd_1.py
# 导入模块subprocess
import subprocess
# 定义一个函数run_cmd进行subprocess.run操作
def run_cmd(command):
    # subprocess.run实例化一个变量return_cmd,需要注意一点,
    # 因为做subprocess.PIPE 有字符需要解码,我这里的encoding使用的是GBK,
    # 这是Windows默认常用的编码字符,因为存在部分中文字符存在异常,当然可以修改,Linux下的是utf8,注意区分
    return_cmd = subprocess.run(command, stdout=subprocess.PIPE,
		stderr=subprocess.PIPE, encoding='GB18030', shell=True,)
    # 判断实例化属性是否有存在异常returncode
    if return_cmd.returncode == 0:
        print("success:")
        print(return_cmd)
    else:
        print("error:")
        print(return_cmd)
run_cmd(["dir", "C:\\"])  # 序列参数
run_cmd("exit 1")  # 字符串参数

执行结果:

success:
CompletedProcess(args=['dir', 'C:\\'], returncode=0, stdout='
驱动器 C 中的卷没有标签。
卷的序列号是 0AA3-2019
C:\\ 的目录
2018/03/16  10:16   <DIR>      EFI
2020/03/18  17:33   <DIR>      Intel
2020/07/13  11:10   <DIR>      LeakHotfix
2020/07/15  16:50   <DIR>      Program Files
2020/07/18  17:40   <DIR>      Program Files (x86)
2020/06/22  11:51   <DIR>      QMDownload
2020/03/18  17:43   <DIR>      Users
2020/07/18  16:55   <DIR>      Windows
0 个文件        0 字节
8 个目录 89,087,983,616 可用字节
', stderr='')
error:
CompletedProcess(args='exit 1', returncode=1, stdout='', stderr='')

image-20220203195809999

我们看到对应的两条命令是没有问题的,成功与错误的信息与returncode的代码显示提示一致,我们的结果也相应地打印处理出来,详解一下参数

  1. args 启动进程的参数,通常是个列表或字符串。
  2. returncode 进程结束状态返回码。0表示成功状态。
  3. stdout 获取子进程的stdout。通常为bytes类型序列,None表示没有捕获值。如果你在调用run()方法时,设置了参数stderr=subprocess.STDOUT,则错误信息会和stdout一起输出,此时stderr的值是None。
  4. stderr 获取子进程的错误信息。通常为bytes类型序列,None表示没有捕获值。
  5. check_returncode() 用于检查返回码。如果返回状态码不为零,弹出 CalledProcessError 异常。
  6. subprocess.DEVNULL用于传递给stdout、stdin和stderr参数。表示使用 os.devnull 作为参数值。
  7. subprocess.PIPE管道,可传递给stdout、stdin和stderr参数。
  8. subprocess.STDOUT特殊值,可传递给stderr参数,表示stdout和stderr合并输出。

这里再详细介绍一下args与shell的参数
args参数可以接收一个类似 ‘ls -la’ 的字符串,也可以传递一个类似 [‘ls’, ‘/b’] 的字符串分割列表

shell参数默认为False,设置为True的时候表示使用操作系统的shell执行命令

一般在命令行里面运行,或者是Linux里面使用时候会默认设置为shell=True

而且在Linux的环境中,args参数为字符串是,shell必须为True

而在Windows的环境中,args参数不论是字符串还是列表,shell建议为True

但是不是所有的操作系统命令都像‘dir’或者‘ipconfig’那样单纯地返回执行结果,还有很多像‘python’这种交互式的命令,如果需要输入点什么,然后它返回执行的结果。使用run()方法怎么向stdin里输入?

错误代码示范

import subprocess
ret = subprocess.run("python", stdin = subprocess.PIPE, stdout = subprocess.PIPE, shell = True)
ret.stdin = "print('haha')" # 错误的用法
print(ret)

这样是不行的,ret作为一个 CompletedProcess 对象,根本没有stdin属性。那怎么办呢?前面说了,run()方法的stdin参数可以接收一个文件句柄。比如在一个 1.txt 文件中写入 print(‘hello,python’) 。然后参考下面的使用方法

>>> import subprocess
>>> fd = open("D:\\1.txt")
>>> ret = subprocess.run("python", stdin=fd, stdout=subprocess.PIPE, shell=True)
>>> print(ret.stdout.decode('utf-8'))
hello,python
>>> fd.close()
>>>

这样做,虽然可以达到目的,但是很不方便,也不是以代码驱动的方式。这个时候,我们可以使用Popen类。
Popen

Popen 是 subprocess的核心,子进程的创建和管理都靠它处理。

用法和参数与run()方法基本类同,但是注意哦,

它的返回值是一个 Popen 对象,而不是 CompletedProcess 对象。

subprocess模块中定义了一个Popen类,通过它可以来创建进程,并与其进行复杂的交互。查看一下它的构造函数:

构造函数:

class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0,restore_signals=True, start_new_session=False, pass_fds=(), * , encoding=None, errors=None)

常用的参数有:

  • args:shell命令,可以是字符串或者序列类型(如:list,元组)
  • bufsize:缓冲区大小。当创建标准流的管道对象时使用,默认-1。 0:不使用缓冲区 1:表示行缓冲,仅当 universal_newlines=True时可用,也就是文本模式 正数:表示缓冲区大小 负数:表示使用系统默认的缓冲区大小。
  • stdin, stdout, stderr:分别表示程序的标准输入、输出、错误句柄
  • preexec_fn:只在 Unix 平台下有效,用于指定一个可执行对象(callable object),它将在子进程运行之前被调用
  • shell:如果该参数为 True,将通过操作系统的 shell 执行指定的命令。 cwd:用于设置子进程的当前目录。
  • env:用于指定子进程的环境变量。如果 env = None,子进程的环境变量将从父进程中继承。

创建一个子进程,然后执行一个简单的命令:

# coding=utf-8
# 文件名:subprocess_cmd.py
# 导入模块subprocess
import subprocess
# 这里我们使用了这么几个参数args,encoding,shell
# dir 在windows命令行中是遍历这个目录下的文件
# Linux可以使用 ls 命令
runcmd = subprocess.Popen(['dir', 'C:\\'], encoding='utf8', shell=True)
# 打印结果
print(runcmd)

查看运行结果:

<subprocess.Popen object at 0x00000295D613C2C8>
驱动器 C 中的卷没有标签。
卷的序列号是 0AA3-2019
C:\ 的目录
2018/03/16  10:16   <DIR>      EFI
2020/03/18  17:33   <DIR>      Intel
......

sub_popen

Popen对象的stdin、stdout和stderr是三个文件句柄,可以像文件那样进行读写操作。而且我们看到其返回值是一个Popen的对象,

>>> import subprocess
>>> ret = subprocess.Popen("python", stdout=subprocess.PIPE, stdin=subprocess.PIPE,
shell=True)
>>> ret.stdin.write(b"import os\n")
10
>>> ret.stdin.write(b"print(os.environ)")
17
>>> ret.stdin.close()
>>> out = ret.stdout.read().decode("GBK")
>>> ret.stdout.close()
>>> print(out)
environ({
  'ALLUSERSPROFILE': 'C:\\ProgramData',
  '......',
  'USERPROFILE': 'C:\\Users\\Administrator.SC-202003181819',
  'WINDIR': 'C:\\Windows'
})

这里看到,通过 s.stdin.write() 可以写入数据,而 s.stdout.read() 则能输出数据。自由度在交互程序,进行交互信息,完成数据的整体提取或写入,这些事Popen类模块提供的比较多的方法

下面是其他的一些比较常用的方法

  • poll(): 检查进程是否终止,如果终止返回 returncode,否则返回 None。
  • wait(timeout): 等待子进程终止。
  • communicate(input,timeout): 和子进程交互,发送和读取数据。
  • send_signal(singnal): 发送信号到子进程 。
  • terminate(): 停止子进程,也就是发送SIGTERM信号到子进程。
  • kill(): 杀死子进程。发送 SIGKILL 信号到子进程。
multiprocessing模块

multiprocessing 是一个用与 threading 模块相似API的支持产生进程的包。 multiprocessing 包同时提供本地和远程并发,使用子进程代替线程,有效避免 Global Interpreter Lock 带来的影响。因此, multiprocessing 模块允许程序员充分利用机器上的多个核心。Unix 和 Windows 上都可以运行。multiprocessing 模块还引入了在threading 模块中没有类似物的API。这方面的一个主要例子是 Pool 对象,它提供了一种方便的方法,可以跨多个输入值并行化函数的执行,跨进程分配输入数据(数据并行)。

multiprocessing模块的功能众多,支持子进程,通信,共享数据,执行不同形式的同步。为些它提供了Process、Queue、Pipe、Lock等组件
注意:使用 multiprocessing模块 if __name__ == '__main__' 部分是必需的,这样避免避免共享状态,避免杀死进程等。总而言之:这是官方规定的编程习惯。所以我们有两个方式创建进程:

  1. 第一种:函数包装:使用一个子进程调用某一个函数

    # -*- coding: UTF-8 -*-
    # 文件名:Process_a.py
    # 导入模块
    from multiprocessing import Process
    import os
    from time import sleep, time
    def test1(name):
        '''
         测试进程
         :param name: 进程对象
         :return: 无返回值
         '''
        print("当前进程的ID", os.getpid())
        print("父进程的ID", os.getppid())
        print("当前进程的名字:", name)
        # 休息3秒
        sleep(3)
    # 入口
    if __name__ == '__main__':
        start = time()
        # 创建多个子进程,并且把这些子进程放入列表中
        process_list = []
        print("主进程的ID", os.getpid())
        for i in range(10):
            # args:表示被调用对象的位置参数元组,这里Process就是属于Process类
            p = Process(target=test1, args=('process-%s' % i,))
            # 开始进程
            p.start()
            process_list.append(p)
    

    既然要等到子进程结束后再执行父进程的后续部分,那么是不是感觉到这样多进程就没什么用了?其实不然,一般情况下我们的父进程是不会执行任何其它操作的,它会创建多个子进程来进行任务的处理。当这些子进程全部结束完成后,我们再关闭我们的父进程。

    注意: join是等待当前的进程结束

  2. 第二种:类包装:自定义一个Process进程类,该类中的run函数由一个子进程调用执行继承Process类,重写run方法就可以了

    # -*- coding: UTF-8 -*-
    # 文件名:Process_b.py
    # 导入模块
    from multiprocessing import Process
    import os
    from time import sleep, time
    
    
    # 自定义一个进程类 继承Process类
    class MyProcess(Process):
        def __init__(self, name):
            Process.__init__(self)
            self.name = name
    
        def run(self):
            '''
           重写run方法
           :return: 无返回值
           '''
            print("当前进程的ID", os.getpid())
            print("父进程的ID", os.getppid())
            print("当前进程的名字:", self.name)
            sleep(3)
    
    
    # 入口
    if __name__ == '__main__':
        print("主进程ID", os.getpid())
        # 返回当前时间的时间戳
        start = time()
        process_list = []
        for i in range(10):
            # args:表示被调用对象的位置参数元组
            p = MyProcess("process-%s" % i)
            # 开始进程
            p.start()
            process_list.append(p)
        for p in process_list:
            # 我们一般都会需要父进程等待子进程结束再执行父进程后面的代码,需要加join,等待所有的子进程结束
            p.join()
            # 计算时间 每个子进程开始至接受结束运行时间,浮点秒数
            end = time() - start
            print(end)
    
    

总结:

  • 使用进程优点:

    • 可以使用计算机多核,进行任务的并发执行,提高执行效率
    • 运行不受其他进程影响,创建方便
    • 空间独立,数据安全
  • 使用进程缺点:

    进程的创建和删除消耗的系统资源较多

  • 全局变量在多个进程中不能共享

    在子进程中修改全局变量对父进程中的全局变量没有影响。因为父进程在创建子进程时对全局变量做了一个备份,父进程中的全局变量与子进程的全局变量完全是不同的两个变量。全局变量在多个进程中不能共享。

进程间的通信

Python 提供了多种实现进程间通信的机制,主要有以下 2 种:

  1. Python multiprocessing 模块下的 Queue 类,提供了多个进程之间实现通信的诸多方法
  2. Pipe,又被称为“管道”,常用于实现 2 个进程之间的通信,这 2 个进程分别位于管道的两端

Queue 实现进程间通信

需要使用 multiprocessing 模块中的 Queue 类。简单的理解 Queue 实现进程间通信的方式,就是使用了操作系统给开辟的一个队列空间,各个进程可以把数据放到该队列中,当然也可以从队列中把自己需要的信息取走。

现在有这样一个需求:我们有两个进程,一个进程负责写(write)一个进程负责读(read)。当写的进程写完某部分以后要把数据交给读的进程进行使用, 这时候我们就需要使用到了multiprocessing模块的Queue(队列):write() 将写完的数据交给队列,再由队列交给read()

import os, time
from multiprocessing import Process, Queue


class WriterProcess(Process):
    ''' 自定义写类,继承 Process '''

    def __init__(self, name, mq):
        '''
        初始化
        :param name: 写名称
        :param mq: 子进程
        '''
        Process.__init__(self)
        self.name = name
        self.mq = mq

    def run(self):
        '''
        重写 run 方法
        :return: 无返回值
        '''
        print("进程%s,已经启动,ID 是:%s" % (self.name, os.getpid()))
        for i in range(4):
            # write 进程负责把数据写出去
            self.mq.put(i)
            time.sleep(1)
        print("进程%s,已经结束" % self.name)


class ReaderProcess(Process):
    ''' 自定义写类,继承 Process '''

    def __init__(self, name, mq):
        '''
        初始化
        :param name: 写名称
        :param mq: 子进程
        '''
        Process.__init__(self)
        self.name = name
        self.mq = mq

    def run(self):
        '''
        重写 run 方法
        :return: 无返回值
        '''
        # 阻塞,等待获取 write 的值
        while True:
            value = self.mq.get(True)
            print(value)
        print("结束子进程:%s" % self.name)


if __name__ == '__main__':
    ''' 入口'''
    # 父进程创建队列,并传递给子进程
    q = Queue()
    pw = WriterProcess("write", q)
    pr = ReaderProcess("read", q)
    # 进程开始
    pw.start()
    pr.start()

    # 进程结束
    pw.join()
    # pr 是一个死循环,无法等待其结束,只能强行结束
    pr.terminate()
    print("父进程结束")

注意: 代码里面的while循环、join、terminate等函数

Pipe 实现进程间通信

Pipe 直译过来的意思是“管”或“管道”,该种实现多进程编程的方式,和实际生活中的管(管道)是非常类似的。通常情况下,管道有 2 个口,而 Pipe 也常用来实现 2 个进程之间的通信,这 2 个进程分别位于管道的两端,一端用来发送数据,另一端用来接收数据。

  1. send(obj)

    发送一个 obj 给管道的另一端,另一端使用 recv() 方法接收。需要说明的是,该 obj 必须是可序列化的,如果该对象序列化之后超过 32MB,则很可能会引发 ValueError 异常。

  2. recv()

    接收另一端通过 send() 方法发送过来的数据

  3. close()

    关闭连接

  4. poll([timeout])

    返回连接中是否还有数据可以读取

  5. send_bytes(buffer[, offset[, size]])

    发送字节数据。如果没有指定 offset、size 参数,则默认发送 buffer 字节串的全部数据;如果指定了 offset 和 size 参数,则只发送 buffer 字节串中从 offset 开始、长度为 size的字节数据。通过该方法发送的数据,应该使用 recv_bytes() 或 recv_bytes_into 方法接收。

  6. recv_bytes([maxlength])

    接收通过 send_bytes() 方法发送的数据,maxlength 指定 多接收的字节数。该方法返回接收到的字节数据

  7. recv_bytes_into(buffer[, offset])

    功能与 recv_bytes() 方法类似,只是该方法将接收到的数据放在 buffer 中

import os, time
import multiprocessing
from multiprocessing import Process, Pipe


class WriterProcess(Process):
    ''' 自定义写类,继承 Process '''

    def __init__(self, name, pip):
        '''
        初始化
        '''
        Process.__init__(self)
        self.name = name
        self.pip = pip

    def run(self):
        '''
        重写 run 方法
        :return: 无返回值
        '''
        print("进程%s,已经启动,ID 是:%s" % (self.name, multiprocessing.current_process().pid))
        for i in range(4):
            # 子进程通过管道写数据出去
            # write 进程负责把数据写出去
            self.pip.send(i)
            time.sleep(1)
        print("进程%s,已经结束" % self.name)


class ReaderProcess(Process):
    ''' 自定义读类,继承 Process '''

    def __init__(self, name, pip):
        '''
        初始化
        '''
        Process.__init__(self)
        self.name = name
        self.pip = pip

    def run(self):
        '''
        重写 run 方法
        :return: 无返回值
        '''
        # 阻塞,等待获取 write 的值
        while True:
            value = self.pip.recv()
            print(value)
        print("结束子进程:%s" % self.name)


if __name__ == '__main__':
    ''' 入口'''
    # 父进程创建两个 pipe,并传递给子进程
    p1, p2 = Pipe()
    pw = WriterProcess("write", p1)
    pr = ReaderProcess("read", p2)
    # 进程开始
    pw.start()
    pr.start()

    # 进程结束
    pw.join()
    # pr 是一个死循环,无法等待其结束,只能强行结束
    pr.terminate()
    print("父进程结束")

进程池Pool

Python 提供了更好的管理多个进程的方式,就是使用进程池。进程池可以提供指定数量的进程给用户使用,即当有新的请求提交到进程池中时,如果池未满,则会创建一个新的进程用来执行该请求;反之,如果池中的进程数已经达到规定 大值,那么该请求就会等待,只要池中有进程空闲下来,该请求就能得到执行。

使用进程池的优点

  1. 提高效率,节省开辟进程和开辟内存空间的时间及销毁进程的时间
  2. 节省内存空间

Pool中的函数说明:

  • Pool(12):创建多个进程,表示可以同时执行的进程数量。默认大小是CPU的核心数果。
  • join():进程池对象调用join,会等待进程池中所有的子进程结束完毕再去结束父进程。
  • close():如果我们用的是进程池,在调用join()之前必须要先close(),并且在close()之后不能再继续往进程池添加新的进程
  • pool.apply_async(func,args,kwds) : 异步执行 ;将事件放入到进程池队列 。args以元组的方式传参,kwds 以字典的方式传参。
  • pool.apply_sync(func,args,kwds):同步执行;将事件放入到进程池队列。

这里解释一下同步与异步:

同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)。
同步,就是调用某个东西时,调用方得等待这个调用返回结果才能继续往后执行。

异步,和同步相反 调用方不会等待得到结果,而是在调用发出后调用者可用继续执行后续操作,被调用者通过状体来通知调用者,或者通过回掉函数来处理这个调用

# -*- coding: UTF-8 -*-
# 文件名:Pool_a.py
# 导入的模块
import random
from multiprocessing.pool import Pool
from time import sleep, time
import os


def run(name):
    '''自定义run方法'''
    print("%s子进程开始,进程ID:%d" % (name, os.getpid()))
    start = time()
    # 随机休息时间
    sleep(random.choice([1, 2, 3, 4]))
    end = time()
    # 执行进程的时间
    print("%s 子进程结束,进程ID:%d。耗时%.2f" % (name, os.getpid(), end - start))


if __name__ == "__main__":
    '''入口'''
    print("父进程开始")
    # 创建多个进程,表示可以同时执行的进程数量。默认大小是CPU的核心数
    p = Pool(4)
    for i in range(10):
        # 创建进程,放入进程池统一管理,异步非阻塞式
        p.apply_async(run, args=(i,))
    # 如果我们用的是进程池,在调用 join() 之前必须要先 close(),并且在 close() 之后不能再继续往进程池添加新的进程
    p.close()
    # 进程池对象调用join,会等待进程吃中所有的子进程结束完毕再去结束父进程
    p.join()
    print("父进程结束。")

注意:因为我们Pool(4)指定了同时 多只能执行4个进程(Pool进程池默认大小是CPU的核心数),但是我们多放入了6个进程进入我们的进程池,所以程序一开始就会只开启4个进程。 而且子进程执行是没有顺序的,先执行哪个子进程操作系统说了算的。而且进程的创建和销毁也是非常消耗资源的,所以如果进行一些本来就不需要多少耗时的任务你会发现多进程甚至比单进程还要慢

线程的开发

Python 的标准库提供了两个模块:thread 和 threading,thread 是低级模块,threading 是高级模块,对_thread 进行了封装。绝大多数情况下,我们只需要使用threading 这个高级模块。

多线程概念

多线程使得系统可以在单独的进程中执行并发任务。虽然进程也可以在独立的内存空间中并发执行,但是其系统开销会比较大。生成一个新进程必须为其分配独立的地址空间,并维护其代码段、堆栈段和数据段等,这种开销是巨大的。另外,进程间的通信实现也不方便。在程序功能日益复杂的时候,需要有更好的系统模型来满足要求,线程由此产生了。 线程是“轻量级”的,一个进程中的线程使用同样的地址空间,且共享许多资源。启动线程的时间远远小于启动进程的时间和空间,而且,线程间的切换也要比进程间的切换快得多。由于使用同样的地址空间,所以线程之间的数据通信比较方便,一个进程下的线程之间可以直接使用彼此的数据。当然,这种方便性也会带来一些问题,特别是同步问题。 多线程对于那些I/O受限的程序特别适用。其实使用多线程的一个重要目的,就是 大化地利用CPU的资源。当某一线程在等待I/O的时候,另外一个线程可以占用CPU资源。如 简单的GUI程序,一般需要有一个任务支持前台界面的交互,还要有一个任务支持后台的处理。这时候,就适合采用线程模型,因为前台UI是在等待用户的输入或者鼠标单击等操作。除此之外,多线程在网络领域和嵌入式领域的应用也比较多。

多线程类似于同时执行多个不同程序,多线程运行有如下应用:

  • 使用线程可以把占据长时间的程序中的任务放到后台去处理。
  • 用户界面可以更加吸引人,比如用户点击了一个按钮去触发某些事件的处理,可以弹出一个进度条来显示处理的进度。
  • 程序的运行速度可能加快。
  • 在一些等待的任务实现上如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下我们可以释放一些珍贵的资源如内存占用等等。

线程可以分为:

  • 内核线程:由操作系统内核创建和撤销。
  • 用户线程:不需要内核支持而在用户程序中实现的线程。

线程的状态
一个线程在其生命周期内,会在不同的状态之间转换。在任何一个时刻,线程总是处于某种线程状态中。虽然不同的操作系统可以实现不同的线程模型,定义不同的线程状态,但是总的说来,一个线程模型中下面几种状态是通用的。

  1. 就绪状态:线程已经获得了除CPU外的其他资源,正在参与调度,等待被执行。当被调度选中后,将立即执行。
  2. 运行状态:占用CPU资源,正在系统中运行。
  3. 等待(阻塞)状态:暂时不参与调度,等待特定事件发生,如I/O事件。
  4. 中止状态:线程已经运行结束,等待系统回收其线程资源。

Python使用全局解释器锁
(GIL)来保证在解释器中只包含一个线程,并在各个线程之间切换。当GIL可用的时候,处于就绪状态的线程在获取GIL后就可以运行了。线程将在指定的间隔时间内运行。当时间到期后,正在执行的线程将重新进入就绪状态并排队。GIL重新可用并且为就绪状态的线程获取。当然,特定的事件也有可能中断正在运行的线程。具体的线程状态转移将在下面进行详细介绍。

现在,Python语言中已经为各种平台提供了多线程处理能力,包括Windows、Linux等系统平台。在具体的库上,提供了两种不同的方式。一种是低级的线程处理模块_thread,仅仅提供一个 小的线程处理功能集,在实际的代码中 好不要直接使用;另外一种是高级的线程处理模块threading,现在大部分应用的线程实现都是基于此模块的。threading模块是基于thread模块的,部分实现思想来自于Java的threads类。 多线程设计的 大问题是如何协调多个线程。因此,在threading模块中,提供了多种数据同步的方法。为了能够更好地实现线程同步,Python 中提供了Queue模块,用来同步线程。在Queue模块中,含有一个同步的FIFO队列类型,特别适合线程之间的数据通信和同步。 由于大部分程序并不需要有多线程处理的能力,所以在Python启动的时候,并不支持多线程。也就说,Python中支持多线程所需要的各种数据结构特别是GIL还没有创建。当Python虚拟机启动的时候,多线程处理并没有打开,而仅支持单线程。这样做的好处是使得系统处理更加高效。只有当程序中使用了如thread.start_new_thread等方法的时候,Python才意识到需要多线程处理的支持。这时,Python虚拟机才会自动创建多线程处理所需要的数据结构和GIL。

创建线程

Python3 通过两个标准库 _thread 和 threading 提供对线程的支持。

_thread 提供了低级别的、原始的线程以及一个简单的锁,它相比于 threading 模块的功能还是比较有限的。

threading 模块除了包含 _thread 模块中的所有方法外,还提供的其他方法:

  • threading.currentThread(): 返回当前的线程变量。
  • threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
  • threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。

我们的线程创建方法也类似进程两种,第一种函数包装,即方法包装,另一种是类包装,线程的执行统一通过 start()方法。

使用threading模块

使用threading模块来创建线程是很方便的。简单地说,只要将类继承于threading.Thread,然后在init方法中调用 threading.Thread类中的init方法,重写类的run方法就可以了。看一些例子。
单线程执行:

import time


def say():
    print("这是单线程!")
    time.sleep(1)


if __name__ == '__main__':
    start = time.time()
    for i in range(5):
        say()
    end = time.time()
    print(f"使用时间:{end - start}")

多线程执行:

import time
import threading


def say():
    print("这是多线程!")
    time.sleep(1)


if __name__ == '__main__':
    start = time.time()
    for i in range(5):
        # 通过 Threading 的 Thread 方法创建线程,并执行函数
        t = threading.Thread(target = say)
        # 启动线程
        t.start()
    end = time.time()
    print(f"使用时间:{end - start}")

  1. 可以明显看出使用了多线程并发的操作,花费时间要短很多
  2. 创建好的线程,需要调用 start() 方法来启动

我们从多线程与单线程对比,可以很明显的发现多线程的等待时间大大缩减了,程序是通过threading模块下的Thread的类,去实例化一个此对象,并调用方法,实现生成多线程,target参数表示线程需要执行的方法,通过对象的start的方法,开启线程。在使用start方法的时候需要注意,此方法一个线程 多只能调用一次看下面的代码:

# 文件名:threading_c.py
import threading
from time import sleep, ctime


def sing():
    for i in range(3):
        print("正在唱歌...%d" % i)
        sleep(1)


def dance():
    for i in range(3):
        print("正在跳舞...%d" % i)
        sleep(1)


if __name__ == '__main__':
    print('---开始---:%s' % ctime())
    t1 = threading.Thread(target = sing)
    t2 = threading.Thread(target = dance)
    t1.start()
    t2.start()
    sleep(5)  # 屏蔽此行代码,试试看,程序是否立马结束
    print("---结束---:%s" % ctime())

主线程会等待所有的子线程结束后才结束,运行看效果,当我们执行完所有的线程后,主线程后才会打印结束,,但是如果屏蔽,或者将我们的sleep(5)这行代码再来看尼,会发现主程序立马结束,但是线程还在运行?

join方法

该方法将等待,一直到它调用的线程终止. 它的名字表示调用的线程会一直等待,直到指定的线程加入它.

当一个进程启动之后,会默认产生一个主线程,因为线程是程序执行流的 小单元,当设置多线程时,主线程会创建多个子线程,在python中,默认情况下(其实就是setDaemon(False)),主线程执行完自己的任务以后,就退出了,此时子线程会继续执行自己的任务,直到自己的任务结束

而join所完成的工作就是线程同步,即主线程任务结束之后,进入阻塞状态,一直等待其他的子线程执行结束之后,主线程在终止,需要注意的是,不要启动线程后立即join(),很容易造成串行运行,导致并发失效

函数写法

# -*- coding: UTF-8 -*-
# 文件名:join_a.py
# 方法包装-启动多线程
# 导入模块
from threading import Thread
from time import sleep, time


def run(name):
    '''执行任务'''
    print("Threading:{} start".format(name))
    sleep(3)
    print("Threading:{} end".format(name))
    
    
if __name__ == '__main__':
    '''入口'''
    # 开始时间
    start = time()
    # 创建线程列表
    t_list = []
    # 循环创建线程
    for i in range(10):
        t = Thread(target=run, args=('t{}'.format(i),))
        t.start()
        t_list.append(t)
    # 等待线程结束
    for t in t_list:
        t.join()
    # 计算使用时间
    end = time() - start
    print(end)

类写法

# -*- coding: UTF-8 -*-
# 文件名:join_b.py
# 类包装-启动多线程
from threading import Thread
from time import sleep, time


class MyThread(Thread):
    def __init__(self, name):
        Thread.__init__(self)
        self.name = name

        def run(self):
            print("Threading:{} start".format(self.name))
            sleep(3)
            print("Threading:{} end".format(self.name))


if __name__ == '__main__':
    '''入口'''
    # 开始时间
    start = time()
    # 创建线程列表
    t_list = []
    # 循环创建线程
    for i in range(10):
        t = MyThread(f"t{i}")
        t.start()
        t_list.append(t)
        # 等待线程结束
        for t in t_list:
            t.join()
        # 计算时间
        end = time() - start

我们可以看到,join方法下,我们等待子线程执行完后,结束的主线程。主线程在完成自己任务同时,阻塞状态下,等待子线程完成后结束进程。

setDaemon

将线程声明为守护线程,必须在start() 方法调用之前设置, 如果不设置为守护线程程序会被无限挂起。这个方法基本和join是相反的。当我们 在程序运行中,执行一个主线程,如果主线程又创建一个子线程,主线程和子线程 就分兵两路,分别运行,那么当主线程完成想退出时,会检验子线程是否完成。如 果子线程未完成,则主线程会等待子线程完成后再退出。但是有时候我们需要的是 只要主线程完成了,不管子线程是否完成,都要和主线程一起退出,这时就可以 用setDaemon方法

如果没有用户线程,那么守护线程也没有存活下去的意义了

from threading import Thread
import time


def foo():
    print(123)
    time.sleep(1)
    print("end123")


def bar():
    print(456)
    time.sleep(3)
    print("end456")


if __name__ == '__main__':
    t1 = Thread(target=foo)
    t2 = Thread(target=bar)
    t1.daemon = True
    t1.start()
    t2.start()
    print("main----------")

主进程在其代码结束后就已经算运行完毕了(守护进程在此时就被回收),然后主进程会一直等非守护的子进程都运行完毕后回收子进程的资源(否则会产生僵尸进程),才会结束。

主线程在其他非守护线程运行完毕后才算运行完毕(守护线程在此时就被回收)。因为主线程的结束意味着进程的结束,进程整体的资源都将被回收,而进程必须保证非守护线程都运行完毕后才能结束。

这就是通过Thread中join方法,对每个线程都调用了join方法,让主线程等待子线程的完成

join方法还有一个可选的超时参数timeout。如果进程没有正常退出或者通过某个异常退出,且超时的情况下,主线程就不再等待子线程了。由于join的返回值始终是None,所以当在join方法中有超时参数的情况下,join返回后无法判断子线程是否已经结束。这个时候,则必须使用Thread类中的isAlive方法来判断是否发生了超时。

在join中,使用join方法的时候,还需要注意以下问题。

  1. 在超时参数不存在的情况下,join操作将会一直阻塞,直到线程终止。
  2. 一个线程可以多次使用join方法。
  3. 线程不能在自己的运行代码中调用join方法,否则会造成死锁。
  4. 在线程调用start方法之前使用join方法,将会出现错误。

下面给出threading模块中Thread类的常用方法。

image-20220203215220396

总结

  1. 每个线程⼀定会有⼀个名字,尽管上⾯的例⼦中没有指定线程对象的 name,但是python会⾃动为线程指定
    ⼀个名字。
  2. 当线程的run()⽅法结束时该线程完成。
  3. ⽆法控制线程调度程序,但可以通过别的⽅式来影响线程调度的⽅式。
  4. 线程的⼏种状态:新建-> 就绪 -> 运行->等待(阻塞) ->中止(死亡)
同步

同步就是协同步调,按预定的先后次序进⾏运⾏。如:你先走,我再走。

同步的简单方法就是使用锁机制。这在低级thread模块和高级threading模块中都有提供。当然,threading模块中的锁机制也是基于thread模块实现的。在Python中,这是 低层次的数据同步原语。一个锁总是处于下面两种状态之中:“已锁”和“未锁”。为此提供了两种操作:“加锁”和“解锁”,分别用来改变锁的状态。对于一个锁来说,如果是未锁的状态,则线程在进入部分将此锁使用“加锁”操作将其状态变为“已锁”。

由于同一进程中的所有线程都是共享数据的,如果对线程中共享数据的并发访问不加以限制,结果将不可预期,在严重的情况下,还会产生死锁。

irom threading import Thread
import time
g_num = 100
def work1():
    global g_num
  	for i in range(3):
    	g_num += 1
  	print("----in work1, g_num is %d---"%g_num)
def work2():
  	global g_num
  	print("----in work2, g_num is %d---"%g_num)
print("---线程创建之前g_num is %d---"%g_num)
t1 = Thread(target=work1)
t1.start()
#延时一会,保证t1线程中的事情做完
time.sleep(1)
t2 = Thread(target=work2)
t2.start()

看运行结果:

---线程创建之前g_num is 100---
----in work1, g_num is 103---
----in work2, g_num is 103---

Process finished with exit code 0

列表当做实参传递到线程中

from threading import Thread
import time
def work_1(nums):
  	nums.append(44)
  	print("----in work1---",nums)
def work_2(nums):
  	# 延时一会,保证t1线程中的事情做完
  	time.sleep(1)
  	print("----in work2---",nums)
g_nums = [11,22,33]
t1 = Thread(target=work_1, args=(g_nums,))
t1.start()
t2 = Thread(target=work_2, args=(g_nums,))
t2.start()
  • 在一个进程内的所有线程共享全局变量,能够在不使用其他方式的前提下完成多线程之间的数据共享(这点要比多进程要好)
  • 缺点就是,线程是对全局变量随意遂改可能造成多线程之间对全局变量的混乱(即线程非安全)

为了解决这个问题,需要允许线程独占地访问共享数据,这就是线程同步。需要注意的是,这些问题在进程中也是存在的,只是在多线程环境下更见常见而已。
有时候需要在每个线程中使用各自独立的变量,一个显而易见的方法就是每个线程都使用自己的私有变量。为了方便,Python中提供了一种简单的机制threading.local来解决这个问题。其使用方法也很简单,其成员变量就是在每个线程中不同的。看代码

import threading
import time,random
def func1():
    local = threading.local()
    time.sleep(random.random())  # 随机休眠时间
  	local.number = [1]
  	for _ in range(10):
    	# 加入随机数
    	local.number.append(random.choice(range(10)))
  	# 打印当前的线程对象,值
  	print(threading.currentThread(), local.number)
if __name__ == '__main__':
  	threads = []
  	for i in range(5):
    	t = threading.Thread(target=func1)
    	t.start()
    	threads.append(t)
  	for i in range(5):
    	threads[i].join()

在ThreadLocal类中使用了threading.local()生成了类局部变量。此变量将在不同的线程中保存为不同的值。ThreadLocal类中的run方法主要是将10个随机数放到前面生成的局部变量中,并打印出来。

# 下面是这段代码执行的一种结果。

<Thread(Thread-4, started 19172)> [1, 0, 9, 6, 3, 7, 9, 2, 1, 9, 1]
<Thread(Thread-3, started 13448)> [1, 1, 3, 4, 7, 7, 6, 7, 0, 1, 6]
<Thread(Thread-5, started 10988)> [1, 9, 2, 9, 2, 3, 7, 7, 3, 2, 7]
<Thread(Thread-1, started 13584)> [1, 2, 5, 6, 5, 3, 0, 0, 9, 2, 4] 
<Thread(Thread-2, started 3736)> [1, 0, 4, 6, 3, 9, 3, 3, 6, 6, 3]

Process finished with exit code 0

# 从上面的输出结果中可以看到,每个线程都有自己不同的值。

同步锁与GIL的关系

GIL本质是一把互斥锁,但GIL锁住的是解释器级别的数据

同步锁,锁的是解释器以外的共享资源,例如:硬盘上的文件 控制台,对于这种不属于解释器的数据资源就应该自己加锁处理

Python的线程在GIL的控制之下,线程之间,对整个python解释器,对python提供的C API的访问都是互斥的,这可以看作是Python内核级的互斥机制。但是这种互斥是我们不能控制的,我们还需要另外一种可控的互斥机制 ———用户级互斥。内核级通过互斥保护了内核的共享资源,同样,用户级互斥保护了用户程序中的共享资源。

GIL 的作用是:对于一个解释器,只能有一个thread在执行bytecode。所以每时每刻只有一条bytecode在被执行一个thread。GIL保证了bytecode 这层面上是thread safe的。但是如果你有个操作比如 x += 1,这个操作需要多个bytecodes操作,在执行这个操作的多条bytecodes期间的时候可能中途就换thread了,这样就出现了data races的情况了。

假设两个线程t1和t2都要对num=0进行增1运算,t1和t2都各对num修改10次,num的 终的结果应该为20。但是由于是多线程访问,有可能出现下面情况:

在num=0时,t1取得num=0。此时系统把t1调度为”sleeping”状态,把t2转换为”running”状态,t2也获得 num=0。然后t2对得到的值进行加1并赋给num,使得num=1。然后系统又把t2调度为”sleeping”,把t1转为”running”。线程t1又把它之前得到的0加1后赋值给num。这样,明明t1和t2都完成了1次加1工作,但结果仍然是num=1。

from threading import Thread
def func1(name):
  	print('Threading:{} start'.format(name))
  	global num
  	for i in range(50000000):  # 有问题
    	# for i in range(5000): # 无问题
    	num += 1
  	print('Threading:{} end num={}'.format(name, num))
if __name__ == '__main__':
  	num = 0
  	# 创建线程列表
  	t_list = []
  	# 循环创建线程
  	for i in range(5):
    	t = Thread(target=func1, args=('t{}'.format(i),))
    	t.start()
    	t_list.append(t)
  	# 等待线程结束
  	for t in t_list:
    	t.join()

运行结果(可能不一样,但是结果往往不是正确的结果):

数字设置小一些之后,再次运行结果就是正确的:

问题产生的原因就是没有控制多个线程对同一资源的访问,对数据造成破坏,使得线程运行的结果不可预期。这种现象称为“线程不安全”。

同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。

"同"字从字面上容易理解为一起动作

其实不是,"同"字应是指协同、协助、互相配合。

如进程、线程同步,可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。这既是同步,

当线程间共享全局变量,多个线程对该变量执行不同的操作时,该变量 终的结果可能是不确定的(每次线程执行后的结果不同),如:对变量执行加减操作,变量的值是不确定的,要想变量的值是一个确定的需对线程执行的代码段加锁。

python对线程加锁主要有Lock和Rlock模块

看下面的代码:

from threading import Thread, Lock
def func1(name):
  	print('Threading:{} start'.format(name))
  	global num
  	lock.acquire()
  	for i in range(1000000):  # 有问题
    	# for i in range(5000): # 无问题
    	num += 1
  	lock.release()
  	print('Threading:{} end num={}'.format(name, num))
if __name__ == '__main__':
  	# 创建锁
  	lock = Lock()
  	num = 0
  	# 创建线程列表
  	t_list = []
  	# 循环创建线程
  	for i in range(5):
    	t = Thread(target=func1, args=('t{}'.format(i),))
    	t.start()
    	t_list.append(t)
# 等待线程结束
# for t in t_list:
# t.join()

注意:

  1. 加锁还可以使用with 效果一样
  2. 必须使用同一把锁
  3. 如果使用锁,程序会变成串行,因此应该是在适当的地方加锁 线程调度本质上是不确定的,因此,在多线程程序中错误地使用锁机制可能会导致随机数 据损坏或者其他的异常行为,我们称之为竞争条件。为了避免竞争条件, 好只在临界区(对 临界资源进行操作的那部分代码)使用锁

对于全局变量,在多线程中要格外小心,否则容易造成数据错乱的情况发生,那对非全局变量是否要加锁呢?我们来看看下面两段代码:

代码1:

import threading
import time
class MyThread(threading.Thread):
  	# 重写 构造方法
  	def __init__(self, num, sleepTime):
    	threading.Thread.__init__(self)
    	self.num = num
    	self.sleepTime = sleepTime
  	def run(self):
    	self.num += 1
    	time.sleep(self.sleepTime)
    	print('线程(%s),num=%d' % (self.name, self.num))
if __name__ == '__main__':
  	mutex = threading.Lock()
  	t1 = MyThread(100, 5)
  	t1.start()
  	t2 = MyThread(200, 1)
  	t2.start()

运行结果:

线程(Thread-2),num=201
线程(Thread-1),num=101

Process finished with exit code 0

代码2:

import threading
from time import sleep
def test(sleepTime):
  	num = 1
  	sleep(sleepTime)
  	num += 1
  	print('---(%s)--num=%d' % (threading.current_thread(), num))
t1 = threading.Thread(target=test, args=(5,))
t2 = threading.Thread(target=test, args=(1,))
t1.start()
t2.start()

运行结果:

---(<Thread(Thread-2, started 9872)>)--num=2
---(<Thread(Thread-1, started 2340)>)--num=2

Process finished with exit code 0

我们看到运行结果,与程序上的对比,实际上对于局部变量,是独立非共享的程序,每一个线程拿到的值,是相对独立的,在各自的线程内。所以运行的结果,在第二段代码上非常明显,不同的暂停时间上,运算num += 1结果是一致的。

在多线程开发中,全局变量是多个线程都共享的数据,而局部变量等是各自线程的,是非共享的

死锁

在多线程程序中,死锁问题很大一部分是由于线程同时获取多个锁造成的。

在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源,就会造成死锁。

尽管死锁很少发生,但一旦发生就会造成应用的停止响应。看代码代码:

import threading
import time
class MyThread1(threading.Thread):
  	def run(self):
    	if mutexA.acquire():
      		print(self.name+'----do1---up----')
      		time.sleep(1)
      	if mutexB.acquire():
        	print(self.name+'----do1---down----')
        	mutexB.release()
      	mutexA.release()
class MyThread2(threading.Thread):
  	def run(self):
    	if mutexB.acquire():
      		print(self.name+'----do2---up----')
      		time.sleep(1)
      		if mutexA.acquire():
        		print(self.name+'----do2---down----')
        		mutexA.release()
      		mutexB.release()
mutexA = threading.Lock()
mutexB = threading.Lock()
if __name__ == '__main__':
  	t1 = MyThread1()
  	t2 = MyThread2()
  	t1.start()
  	t2.start()

看运行结果:

Thread-1----do1---up----
Thread-2----do2---up----

Process finished with exit code -1

此时已经进入死锁状态,命令行可以可以使用ctrl-z退出,

当我们执行线程t1与t2时候,我们的程序,第一步就是对双方所需占用的资源上锁,我们可以获取到上锁后的一部分信息,至此程序运转继续,等待对方的资源解锁,但是解锁操作无法完成,因为解锁条件就是对方完成解锁才能解锁。
产生死锁的四个必要条件

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

image-20220205133511971

代码:

from threading import Thread,Lock
from time import sleep
class Task1(Thread):
  	def run(self):
    	while True:
      		if lock1.acquire():
        		print("------Task 1 -----")
        		sleep(0.5)
    	    	lock2.release()
class Task2(Thread):
  	def run(self):
    	while True:
      		if lock2.acquire():
        		print("------Task 2 -----")
        		sleep(0.5)
        		lock3.release()
class Task3(Thread):
  	def run(self):
    	while True:
      		if lock3.acquire():
        		print("------Task 3 -----")
        		sleep(0.5)
        		lock1.release()
# 使用Lock创建出的锁默认没有“锁上”
lock1 = Lock()
# 创建另外一把锁,并且“锁上”
lock2 = Lock()
lock2.acquire()
# 创建另外一把锁,并且“锁上”
lock3 = Lock()
lock3.acquire()
t1 = Task1()
t2 = Task2()
t3 = Task3()
t1.start()
t2.start()
t3.start()

可以使用互斥锁完成多个任务,有序的进程工作,这就是线程的同步,按照指定的顺序执行指定的任务。

线程间的通信

信号量

我们都知道在加锁的情况下,程序就变成了串行,也就是单线程,而有时,我们在不用考虑数据安全时,不用加锁,程序就变成了并行,也就是多线程。为了避免业务开启过多的线程时。我们就可以通过信号量(Semaphore)来设置指定个数的线程。举个简单例子:车站有3 个安检口,那么同时只能有3 个人安检,别人来了,只能等着别人安检完才可以过。

from threading import Thread, BoundedSemaphore
from time import sleep
def an_jian(num):
  	semapshore.acquire()
  	print('第{}个人安检完成!'.format(num))
  	sleep(2)
  	semapshore.release()
if __name__ == '__main__':
  	semapshore = BoundedSemaphore(3)
  	for i in range(20):
    	thread = Thread(target=an_jian, args=(i,))
    	thread.start()

Queue
从一个线程向另一个线程发送数据 安全的方式可能就是使用queue 库中的队列了。创建一个被多个线程共享的 Queue 对象,这些线程通过使用put() 和get() 操作来向队列中添加或者删除元素。Queue 对象已经包含了必要的锁,所以你可以通过它在多个线程间多安全地共享数据。

Python 包括FIFO(先入先出)队列Queue,LIFO(后入先出)队列LifoQueue,和优先级队列PriorityQueue。 Queue 中包含以下方法:

  • Queue.qsize() 返回队列的大小
  • Queue.empty() 如果队列为空,返回True,反之False
  • Queue.full() 如果队列满了,返回True,反之False
  • Queue.full 与maxsize 大小对应
  • Queue.get([block[, timeout]])获取队列,timeout 等待时间
  • Queue.get_nowait() 相当Queue.get(False)
  • Queue.put(item) 写入队列,timeout 等待时间
  • Queue.put_nowait(item) 相当Queue.put(item, False)
  • Queue.taskdone() 在完成一项工作之后,Queue.taskdone()函数向任务已经完成的队列发送一个信号
  • Queue.join() 实际上意味着等到队列为空,再执行别的操作

Python的Queue模块中提供了同步的、线程安全的队列类,包括FIFO(先入先出)队列Queue,LIFO(后入先出)队列LifoQueue,和优先级队列PriorityQueue。这些队列都实现了锁原语(可以理解为原子操作,即要么不做,要么就做完),能够在多线程中直接使用。可以使用队列来实现线程间的同步。

用FIFO队列实现上述生产者与消费者问题的代码如下:

import threading
import time
from queue import Queue
class Producer(threading.Thread):
  	def run(self):
    	global queue
    	count = 0
    	while True:
      		if queue.qsize() < 1000:
        		for i in range(100):
          		count = count + 1
          		msg = '生成产品' + str(count)
          		queue.put(msg)
          		print(msg)
      		time.sleep(0.5)
class Consumer(threading.Thread):
  	def run(self):
    	global queue
    	while True:
    	  	if queue.qsize() > 100:
	        	for i in range(3):
          			msg = self.name + '消费了 ' + queue.get()
          			print(msg)
      		time.sleep(1)
if __name__ == '__main__':
  	queue = Queue()
  	for i in range(500):
    	queue.put('初始产品' + str(i))
  	for i in range(2):
    	p = Producer()
    	p.start()
  	for i in range(5):
    	c = Consumer()
    	c.start()
  1. 对于Queue,在多线程通信之间扮演重要的角色
  2. 添加数据到队列中,使用put()方法
  3. 从队列中取数据,使用get()方法
  4. 判断队列中是否还有数据,使用qsize()方法

image-20220205135852237

在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。

生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

上面的代码如果不加以人工干预,本代码将会一直执行下去。从输出结果来看,除了 初还有部分剩余产品外,后面只要产品生产出来后就被消费了。这也是可以解释的,因为消费者消费产品的速度要快于生产者生产产品的速度。

事件Event

线程的一个关键特性是每个线程都是独立运行且状态不可预测。如果程序中的其 他线程需要通过判断某个线程的状态来确定自己下一步的操作,这时线程同步问题就会变得非常棘手。为了解决这些问题,我们需要使用threading库中的Event对象。 对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在 初始情况下,Event对象中的信号标志被设置为假。如果有线程等待一个Event对象, 而这个Event对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。一个线程如果将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。如果一个线程等待一个已经被设置为真的Event对象,那么它将忽略这个事件, 继续执行

Event()可以创建一个事件管理标志,该标志(event)默认为False,event 对象主要有四种方法可以调用:

event.wait(timeout=None):调用该方法的线程会被阻塞,如果设置了timeout 参数,超时后,线程会停止阻塞继续执行;

event.set():将event 的标志设置为True,调用wait 方法的所有线程将被唤醒;

event.clear():将event 的标志设置为False,调用wait 方法的所有线程将被阻塞;

event.is_set():判断event 的标志是否为True。

import threading
import time
import random
#线程1,门,一开始是打开的,每3秒需要自动关闭一下。如果有人通过,需要重新刷卡打开
#线程2,人,人通过门,如果门是打开的直接通过,如果没有打开需要刷卡。之后门就已经大开了,通知人继续进入
event = threading.Event() #创建一个事件
event.set() # 设置标志位真 ,门一开始就是打开的
status = 0 # status代表门的状态,如果是0~3代表打开,如果等于3,需要关闭
def door():
  	global status
  	while True:
    	print("当前门的status为:{}".format(status))
    	if status>=3:
      		print("当门已经打开了3秒,需要自动关闭")
      		event.clear()
    	if event.is_set():
      		print('当前门是开着的,可以通行!')
    	else:
      		print('门已经关了,请用户自己刷卡!')
	      	event.wait() # 门的线程阻塞等待
      		continue
    	time.sleep(1)
    	status+=1 # status代表们开始的秒数
def person():
  	global status
  	n =0 # 人的计数器,看看有多少人进入到门里面
  	while True:
    	n+=1
    	if event.is_set():
      		print('门开着,{}号人进入门里面'.format(n))
    	else:
      		print('门关着,{}号人刷卡之后,进入门里面'.format(n))
      		event.set() # 标志改为true
      		status =0
    	time.sleep(random.randint(1,10))
if __name__ == '__main__':
  	d = threading.Thread(target=door)
  	p = threading.Thread(target=person)
  	d.start()
  	p.start()
异步

同步: 就是女儿叫你起床,你还没有起来,她一直在床边等着也不去早读,等你起来之后,再去早读。

异步:就是女儿叫你起床,你还没有起来,她对着你房间喊一声“起床了”,然后就不管了,自己去早读了。

异步应用

  • 为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式。
  • 不相关的程序单元之间可以是异步的。
  • 例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。异步意味着无序。

如果在某程序的运行时,能根据已经执行的指令准确判断它接下来要进行哪个具体操作,那它是同步程序,反之则为异步程序。(无序与有序的区别)

同步/异步、阻塞/非阻塞并非水火不容,要看讨论的程序所处的封装级别。例如购物程序在处理多个用户的浏览请求可以是异步的,而更新库存时必须是同步的

rom multiprocessing import Pool
import time
import os
def test():
  	print("---进程池中的进程---pid=%d,ppid=%d--" % (os.getpid(), os.getppid()))
  	for i in range(3):
    	print("----%d---" % i)
    	time.sleep(1)
  	return "hahah"
def test2(args):
  	print("---callback func--pid=%d" % os.getpid())
  	print("---callback func--args=%s" % args)
if __name__ == '__main__':
  	# 创建进程池及制定数量
  	pool = Pool(3)
  	# 非阻塞支持回调
  	pool.apply_async(func=test, callback=test2)
  	time.sleep(5)
  	print("----主进程-pid=%d----" % os.getpid()

我们看到,异步调用实际想要同时结束进程,但是线程池中的其他进程有需要执行任务,等待执行任务完成结束去与其他进程回合,这里的参数通过回调,直至程序结束。

协程

协程(coroutine),又称为微线程,纤程。(协程是一种用户态的轻量级线程)

作用:在执行A 函数的时候,可以随时中断,去执行B 函数,然后中断继续执行A 函数(可以自动切换),注意这一过程并不是函数调用(没有调用语句),过程很像多线程,然而协程只有一个线程在执行

image-20220205140912421

对于单线程下,我们不可避免程序中出现io 操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io 阻塞时就将寄存器上下文和栈保存到其他地方,切换到另外一个任务去计算。在任务切回来的时候,恢复先前保存的寄存器上下文和栈,这样就保证了该线程能够 大限度地处于就绪态,即随时都可以被cpu 执行的状态,相当于我们在用户程序级别将自己的io 操作 大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io 比较少,从而更多的将cpu的执行权限分配给我们的线程(注意:线程是CPU 控制的,而协程是程序自身控制的)

协作的标准

  • 必须在只有一个单线程里实现并发
    • 修改共享数据不需加锁
    • 用户程序里自己保存多个控制流的上下文栈
    • 一个协程遇到 IO 操作自动切换到其它协程

由于自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级;无需原子操作的锁定及同步的开销;方便切换控制流,简化编程模型,单线程内就可以实现并发的效果,大限度地利用cpu,且可扩展性高,成本低(注:一个CPU 支持上万的协程都不是问题。所以很适合用于高并发处理)

他的缺点:无法利用多核资源:协程的本质是个单线程,它不能同时将单个CPU 的多个核用上,协程需要和进程配合,才能运行在多CPU 上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu 密集型应用。进行阻塞(Blocking)操作(如IO 时)会阻塞掉整个程序

greenlet

from greenlet import greenlet
def attack(name):
  	print(f'{name} :我要买包!')  # 2
  	gree_b.switch('吕布')  # 3
  	print(f'{name} :我要去学编程!')  # 6
  	gree_b.switch()  # 7
def player(name):
  	print(f'{name} :买买买!! ')  # 4
  	gree_a.switch()  # 5
  	print(f'{name} :一定去马士兵教育!!!!')  # 8
gree_a = greenlet(attack)
gree_b = greenlet(player)
gree_a.switch('貂蝉')  # 可以在第一次switch 时传入参数,以后都不需要#1

Gevent 模块

Gevent 是一个第三方库,可以轻松通过gevent 实现并发同步或异步编程,在gevent中用到的主要模式是
Greenlet,它是以C 扩展模块形式接入Python 的轻量级协程。Greenlet 全部运行在主程序操作系统进程的内部,但他们被协作式地调度。

当一个greenlet遇到IO操作时,比如访问网络/睡眠等待,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。同时也因为只有一个线程在执行,会极大的减少上下文切换的成本。

安装模块

pip install gevent

代码:

import gevent
def gf(name):
  	print(f'{name} :我要买包!')  # 2
  	gevent.sleep(2)  # 3
  	print(f'{name} :我要去学编程!')  # 6
def bf(name):
  	print(f'{name} :买买买!! ')  # 4
  	gevent.sleep(2)
  	print(f'{name} :一定去马士兵教育!!!!')  # 8
geven_a = gevent.spawn(gf, '小乔')
geven_b = gevent.spawn(bf, name='周瑜')
gevent.joinall([geven_a, geven_b])

注意:上例gevent.sleep(2)模拟的是gevent 可以识别的io 阻塞;

而time.sleep(2)或其他的阻塞,gevent 是不能直接识别的,需要加入一行代码monkey.patch_all(),这行代码需在 time,socket 模块之前。

async io 异步IO asyncio 是python3.4 之后的协程模块,是python 实现并发重要的包,这个包使用事件循环驱动实现并发。事件循环是一种处理多并发量的有效方式,在维基百科中它被描述为「一种等待程序分配事件或消息的编程架构」,我们可以定义事件循环来简化使用轮询方法来监控事件,通俗的说法就是「当A 发生时,执行B」。事件循环利用poller 对象,使得程序员不用控制任务的添加、删除和事件的控制。事件循环使用回调方法来知道事件的发生。

image-20220205141903444

看代码

件名:asyn_b.py
import asyncio
@asyncio.coroutine  # python3.5 之前
def func_a():
  	for i in range(5):
    	print('协程——a!!')
    	yield from asyncio.sleep(1)
async def func_b():  # python3.5 之后
  	for i in range(5):
    	print('协程——b!!!')
    	await asyncio.sleep(2)
# 创建协程对象
asy_a = func_a()
asy_b= func_b()
# 获取事件循环
loop = asyncio.get_event_loop()
# 监听事件循环
loop.run_until_complete(asyncio.gather(asy_a, asy_b))
# 关闭事件
loop.close()

协程的嵌套:使用async可以定义协程,协程用于耗时的io操作,我们也可以封装更多的io操作过程,这样就实现了嵌套的协程,即一个协程中await了另外一个协程,如此连接起来。

import asyncio
async def compute(x, y):
  	print(f"compute: {x}+{y} ...")
  	await asyncio.sleep(1)
  	return x + y
async def print_sum(x, y):
  	result = await compute(x, y)
  	print(f"{x}+{y}={result}")
loop = asyncio.get_event_loop()
loop.run_until_complete(print_sum(1, 2))
loop.close()

总结

串行、并行与并发的区别

image-20220205142340420

  • 并行:指的是任务数小于等于 cpu核数,即任务真的是一起执行的
  • 并发:指的是任务数多余 cpu核数,通过操作系统的各种任务调度算法,实现用多个任务“一起”执行(实际上总有一些任务不在执行,因为切换任务的速度相当快,看上去一起执行而已)

进程与线程的区别

image-20220205142601683

  1. 线程是程序执行的 小单位,而进程是操作系统分配资源的 小单位;
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多。

有一个老板想要开个工厂进行生产某件商品(例如:手机)他需要花一些财力物力制作一条生产线,这个生产线上有很多的器件以及材料这些所有的为了能够生产手机而准备的资源称之为:进程。只有生产线是不能够进行生产的,所以老板的找个工人来进行生产,这个工人能够利用这些材料 终一步步的将手机做出来,这个来做事情的工人称之为:线程这个老板为了提高生产率,想到 3种办法:

  1. 在这条生产线上多招些工人,一起来做手机,这样效率是成倍増长,即单进程多线程方式
  2. 老板发现这条生产线上的工人不是越多越好,因为一条生产线的资源以及材料毕竟有限,所以老板又花了些财力物力购置了另外一条生产线,然后再招些工人这样效率又再一步提高了,即多进程多线程方式
  3. 老板发现,现在已经有了很多条生产线,并且每条生产线上已经有很多工人了(即程序是多进程的,每个进程中又有多个线程),为了再次提高效率,老板想了个损招,规定:如果某个员工在上班时临时没事或者再等待某些条件(比如等待另一个工人生产完谋道工序之后他才能再次工作),那么这个员工就利用这个时间去做其它的事情,那么也就是说:如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,其实这就是:协程方式

进程:拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源很 大,效率很低 线程:拥有自己独立的栈和共享的堆,共享堆,不共享栈,标准线程由操作系统调度;线程切换需要的资源一般,效率一般(当然了在不考虑 GIL的情况下) 协程:拥有自己独立的栈和共享的堆,共享堆,不共享栈,协程由程序员在协程的代码里显示调度;协程切换任务资源很小,效率高 多进程、多线程根据 cpu核数不一样可能是并行的,但是协程是在一个线程中所以是并发 选择技术考虑的因素:切换的效率、数据共享的问题、数据安全、是否需要并发

  • 20
    点赞
  • 88
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 16
    评论
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jasonakeke

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值