Python并发编程实例教程之线程的玩法


Posted in Python onJune 20, 2021
目录
  • 一、线程基础以及守护进程
  • 二、线程锁(互斥锁)
  • 三、线程锁(递归锁)
  • 四、死锁
  • 五、队列
  • 六、相关面试题
  • 七、判断数据是否安全
  • 八、进程池 & 线程池
  • 总结

 

一、线程基础以及守护进程

线程是CPU调度的最小单位

全局解释器锁

全局解释器锁GIL(global interpreter lock)

全局解释器锁的出现主要是为了完成垃圾回收机制的回收机制,对不同线程的引用计数的变化记录的更加精准。

全局解释器锁导致了同一个进程中的多个线程只能有一个线程真正被CPU执行。

GIL锁每执行700条指令才会进行一次(轮转)切换(从一个线程切换到另外一个线程)

节省的是IO操作(不占用CPU)的时间,而不是CPU计算的时间,因为CPU的计算速度非常快,大多数情况下,我们没有办法把一条进程中所有的IO操作都规避掉。

threading模块

import time
from threading import Thread, current_thread, enumerate, active_count


def func(i):
    print('start%s' % i, current_thread().ident)  # 函数中获取当前线程id
    time.sleep(1)
    print('end%s' % i)


if __name__ == '__main__':
    t1 = []
    for i in range(3):
        t = Thread(target=func, args=(i,))
        t.start()
        print(t.ident)  # 查看当前线程id
        t1.append(t)
    print(enumerate(), active_count())
    for t in t1:
        t.join()
print('所有线程执行完毕')

线程是不能从外部强制终止(terminate),所有的子线程只能是自己执行完代码之后就关闭。

current_thread 获取当前的线程对象

current_thread().ident 或者 线程对象.ident 获取当前线程id。

enumerate返回一个列表,存储了所有活着的线程对象,包括主线程。

active_count返回一个数字,存储了所有活着的线程个数。

【注意】enumerate导入之后,会和内置函数enumerate重名,需要做特殊的处理

  • from threading import enumerate as en
  • import threading
    threading.enumerate()

面向对象方式开启一个线程

from threading import Thread


class MyThread(Thread):
    def __init__(self, a, b):
        super(MyThread, self).__init__()
        self.a = a
        self.b = b

    def run(self):
        print(self.ident)


t = MyThread(1, 3)
t.start()  # 开启线程,才在线程中执行run方法
print(t.ident)

线程之间的数据是共享的

from threading import Thread

n = 100


def func():
    global n
    n -= 1


t_li = []
for i in range(100):
    t = Thread(target=func)
    t.start()
    t_li.append(t)
for t in t_li:
    t.join()
print(n)

结果是:0

守护线程

  • 主线程会等待子线程结束之后才结束,为什么?

因为主线程结束,进程就会结束。

  • 守护线程随着主线程的结束而结束
  • 守护进程会随着主进程的代码结束而结束,如果主进程代码之后还有其他子进程在运行,守护进程不守护。
  • 守护线程会随着主线程的结束而结束,如果主线程代码结束之后还有其他子线程在运行,守护线程也守护。
import time
from threading import Thread


def son():
    while True:
        print('in son')
        time.sleep(1)


def son2():
    for i in range(3):
        print('in son2...')
        time.sleep(1)


# flag a
t = Thread(target=son)
t.daemon = True
t.start()
# flag b a-->b用时0s
Thread(target=son2).start()

为什么守护线程会在主线程的代码结束之后继续守护其他子线程?

答:因为守护进程和守护线程的结束原理不同。守护进程需要主进程来回收资源,守护线程是随着主线程的结束而结束,其他子线程?>主线程结束?>主进程结束?>整个进程中所有的资源都被回收,守护线程也会被回收。

 

二、线程锁(互斥锁)

线程之间也存在数据不安全

import dis

a = 0


def func():
    global a
    a += 1


dis.dis(func)  # 得到func方法中的代码翻译成CPU指令
"""
结果
0 LOAD_GLOBAL              0 (a)
2 LOAD_CONST               1 (1)
4 INPLACE_ADD
6 STORE_GLOBAL             0 (a)
8 LOAD_CONST               0 (None)
10 RETURN_VALUE
"""

+=、-=、*=、/=、while、if、带返回值的方法(都是先计算后赋值,前提要涉及到全局变量或静态变量) 等都是数据不安全的,append、pop、queue、logging模块等都是数据安全的。

列表中的方法或者字典中的方法去操作全局变量的时候,数据是安全的。

只有一个线程,永远不会出现线程不安全现象。

采用加锁的方式来保证数据安全。

from threading import Thread, Lock

n = 0


def add(lock):
    for i in range(500000):
        global n
        with lock:
            n += 1


def sub(lock):
    for i in range(500000):
        global n
        with lock:
            n -= 1


t_li = []
lock = Lock()
for i in range(2):
    t1 = Thread(target=add, args=(lock,))
    t1.start()
    t2 = Thread(target=sub, args=(lock,))
    t2.start()
    t_li.append(t1)
    t_li.append(t2)
for t in t_li:
    t.join()
print(n)

线程安全的单例模式

import time
from threading import Thread, Lock


class A:
    __instance = None
    lock = Lock()

    def __new__(cls, *args, **kwargs):
        with cls.lock:
            if not cls.__instance:
                time.sleep(0.00001)
                cls.__instance = super().__new__(cls)
        return cls.__instance


def func():
    a = A()
    print(a)


for i in range(10):
    Thread(target=func).start()

不用考虑加锁的小技巧

  • 不要操作全局变量
  • 不要在类中操作静态变量

因为多个线程同时操作全局变量/静态变量,会产生数据不安全现象。

 

三、线程锁(递归锁)

from threading import Lock, RLock

# Lock 互斥锁
# RLock 递归(recursion)锁

l = Lock()
l.acquire()
print('希望被锁住的代码')
l.release()

rl = RLock()  # 在同一个线程中可以被acquire多次
rl.acquire()
rl.acquire()
rl.acquire()
print('希望被锁住的代码')
rl.release()
from threading import Thread, RLock


def func(i, lock):
    lock.acquire()
    lock.acquire()
    print(i, ':start')
    lock.release()
    lock.release()
    print(i, ':end')


lock = RLock()
for i in range(5):
    Thread(target=func, args=(i, lock)).start()

互斥锁与递归锁

递归锁在同一个线程中可以被acquire多次,而互斥锁不行

互斥锁效率高,递归锁效率相对低

多把互斥锁容易产生死锁现象,递归锁可以快速解决死锁

 

四、死锁

死锁:指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象。

死锁现象是怎么产生的?

答:有多把锁,并且在多个线程中交叉使用。与互斥锁、递归锁无关,都会发生死锁。如果是互斥锁,出现了死锁现象,最快速的解决方案是把所有的互斥锁都改成一把递归锁(noodle_lock = fork_lock = RLock()),程序的效率会降低。

from threading import Thread, Lock
import time
noodle_lock = Lock()
fork_lock = Lock()


def eat1(name):
    noodle_lock.acquire()
    print(name, '抢到面了')
    fork_lock.acquire()
    print(name, '抢到叉子了')
    print(name, '吃面')
    time.sleep(0.0001)
    fork_lock.release()
    print(name, '放下叉子了')
    noodle_lock.release()
    print(name, '放下面了')


def eat2(name):
    fork_lock.acquire()
    print(name, '抢到叉子了')
    noodle_lock.acquire()
    print(name, '抢到面了')
    print(name, '吃面')
    noodle_lock.release()
    print(name, '放下面了')
    fork_lock.release()
    print(name, '放下叉子了')


Thread(target=eat1, args=('lucy',)).start()
Thread(target=eat2, args=('jack',)).start()
Thread(target=eat1, args=('rose',)).start()
Thread(target=eat2, args=('disen',)).start()

 

五、队列

队列:线程之间数据安全的容器

线程队列:数据安全,先进先出

原理:加锁 + 链表

Queue

fifo 先进先出的队列

get和put

import queue


q = queue.Queue(3)  # fifo 先进先出的队列

q.put(1)
q.put(2)
print(q.get())
print(q.get())

1
2

get_nowait

import queue

# from queue import Empty  # 不是内置的错误类型,而是queue模块中的错误
q = queue.Queue()  # fifo 先进先出的队列
try:
    q.get_nowait()
except queue.Empty:
    pass
print('队列为空,继续执行其他代码')

put_nowait

用的很少,因为队列满时,抛异常,数据放不进去,丢失了。

LifoQueue

后进先出的队列,也就是栈。last in first out

from queue import LifoQueue
lq = LifoQueue()
lq.put(1)
lq.put(2)
print(lq.get())
print(lq.get())

2
1

PriorityQueue

优先级队列,按照放入数据的第一位数值从小到大输出

from queue import PriorityQueue

priq = PriorityQueue()
priq.put((2, 'lucy'))
priq.put((0, 'rose'))
priq.put((1, 'jack'))
print(priq.get())
print(priq.get())
print(priq.get())

(0, 'rose')
(1, 'jack')
(2, 'lucy')

三种队列使用场景

先进先出:用于处理服务类任务(买票任务)

后进先出:算法中用的比较多

优先级队列:比如,VIP制度,VIP用户优先;

 

六、相关面试题

请聊聊进程队列的特点和实现原理
特点:实现进程之间的通信;数据安全;先进先出。

实现原理:基于管道 + 锁 实现的,管道是基于文件级别的socket + pickle 实现的。

你了解生产者消费者模型吗,如何实现
了解

为什么了解?工作经验

 采集图片/爬取音乐:由于要爬取大量的数据,想提高爬取效率

 有用过一个生产者消费者模型,这个模型是我自己写的,消息中间件,用的是xxx(redis),我获取网页的过程作为生产者,分析网页,获取所有歌曲歌曲链接的过程作为消费者。

 自己写监控,或者是自己写邮件报警系统,监控程序作为生产者,一旦发现有问题的程序,就需要把要发送的邮件信息交给消息中间件redis,消费者就从中间件中取值,然后来处理发邮件的逻辑。

什么时候用过?

 项目 或者 例子,结合上面一起

在python中实现生产者消费者模型可以用哪些机制

 消息中间件

 celery(分布式框架):定时发短信的任务

从你的角度说说进程在计算机中扮演什么角色

进程用来管理一个运行中的程序的资源,是资源分配的最小单位

进程与进程之间内存是隔离的

进程是由操作系统负责调度的,并且多个进程之间是一种竞争关系,所以我们应该对进程的三状态时刻关注,尽量减少进程中的IO操作,或者在进程里面开线程来规避IO,让我们写的程序在运行的时候能够更多的占用CPU资源。

为什么线程之间的数据不安全
线程之间数据共享

多线程的情况下,

 如果在计算某一个变量的时候,还要进行赋值操作,这个过程不是由一条完整的CPU指令完成的;

 如果在判断某个bool表达式之后,再做某些操作,这个过程也不是由一条完整的CPU指令完成的;

 在中间发生了GIL锁的切换(时间片的轮转),可能会导致数据不安全。

读程序,请确认执行到最后number的长度是否一定为 1

import threading
import time

# loop = 1E7  # 10000000.
loop = int(1E7)  # 10000000


def _add(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers.append(0)


def _sub(loop: int = 1):
    global numbers
    for _ in range(loop):
        while not numbers:
            time.sleep(1E-8)
        numbers.pop()


numbers = [0]
ta = threading.Thread(target=_add, args=(loop,))
ts = threading.Thread(target=_sub, args=(loop,))
# ts1 = threading.Thread(target=_sub, args=(loop,))

ta.start()
ts.start()
# ts1.start()

ta.join()
ts.join()
# ts1.join()

因为只开启了一个进行pop操作的线程,如果开启多个pop操作的线程,必须在while前面加锁,因为可能有两个线程,一个执行了while not numbers,发生了GIL的切换,另外一个线程执行完了代码,numbers刚好没有了数据,导致结果一个pop成功,一个pop不成功。

所以number长度一定为1,如果把注释去了,不一定为1

读程序,请确认执行到最后number的长度是否一定为 1

import threading
import time

loop = int(1E7)


def _add(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers.append(0)


def _sub(loop: int = 1):
    global numbers
    for _ in range(loop):
        while not numbers:
            time.sleep(1E-8)
        numbers.pop()


numbers = [0]
ta = threading.Thread(target=_add, args=(loop,))
ts = threading.Thread(target=_sub, args=(loop,))

ta.start()
ta.join()
ts.start()
ts.join()

一定为1,因为是同步的。

读程序,请确认执行到最后number是否一定为 0

import threading

loop = int(1E7)


def _add(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers += 1


def _sub(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers -= 1


numbers = 0
ta = threading.Thread(target=_add, args=(loop,))
ts = threading.Thread(target=_sub, args=(loop,))

ta.start()
ta.join()
ts.start()
ts.join()

一定等于0,因为是同步的。

读程序,请确认执行到最后number是否一定为 0

import threading

loop = int(1E7)


def _add(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers += 1


def _sub(loop: int = 1):
    global numbers
    for _ in range(loop):
        numbers -= 1


numbers = 0
ta = threading.Thread(target=_add, args=(loop,))
ts = threading.Thread(target=_sub, args=(loop,))

ta.start()
ts.start()
ta.join()
ts.join()

不一定为0,因为是异步的且存在 += 操作

 

七、判断数据是否安全

是否数据共享,是同步还是异步(数据共享并且异步的情况下)

  • +=、-=、*=、/=、a = 计算之后赋值给变量
  • if、while 条件,这两个判断是由多个线程完成的

这两种情况下,数据不安全。

 

八、进程池 & 线程池

以前,有多少个任务就开多少个进程或线程。

什么是池

要在程序开始的时候,还没有提交任务,先创建几个线程或者进程,放在一个池子里,这就是池

为什么要用池

如果先开好进程/线程,那么有任务之后就可以直接使用这个池中的数据了;并且开好的进程/线程会一直存在在池中,可以被多个任务反复利用,这样极大的减少了开启/关闭/调度进程/调度线程的时间开销。

池中的线程/进程个数控制了操作系统需要调用的任务个数,控制池中的单位,有利于提高操作系统的效率,减轻操作系统的负担。

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# threading模块 没有提供池
# multiprocessing模块 仿照threading增加了Pool(逐渐被淘汰)
# concurrent.futures模块 线程池,进程池都能够用相似的方式开启/使用
ThreadPoolExecutor()  # 参数代表开启多少个线程,线程的个数一般起cpu个数*4(或者*5)
ProcessPoolExecutor()  # 参数代表开启多少个进程,进程的个数一般起cpu个数+1

创建线程池并提交任务

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time


def func(a, b):
    print(current_thread().ident, a, b)
    time.sleep(1)


tp = ThreadPoolExecutor(4)  # 创建线程池对象
for i in range(20):
    # tp.submit(func, i, i + 1)
    # 向池中提交任务
    tp.submit(func, a=i, b=i + 1)  # 位置传参,关键字传参都可以

创建进程池并提交任务

from concurrent.futures import ProcessPoolExecutor
import os
import time


def func(a, b):
    print(os.getpid(), 'start', a, b)
    time.sleep(1)
    print(os.getpid(), 'end', a, b)


if __name__ == '__main__':
    tp = ProcessPoolExecutor(4)  # 创建进程池对象
    for i in range(20):
        # tp.submit(func, i, i + 1)
        # 向池中提交任务
        tp.submit(func, a=i, b=i + 1)  # 位置传参,关键字传参都可以

获取任务结果

from concurrent.futures import ProcessPoolExecutor
import os
import time


def func(a, b):
    print(os.getpid(), 'start', a, b)
    time.sleep(1)
    print(os.getpid(), 'end', a, b)
    return a * b


if __name__ == '__main__':
    tp = ProcessPoolExecutor(4)  # 创建进程池对象
    future_d = {}
    for i in range(20):  # 异步非阻塞的
        ret = tp.submit(func, a=i, b=i + 1)  # future未来对象
        # print(ret)  # <Future at 0x1ad918e1148 state=running>
        # print(ret.result())  # 这样需要等待,同步的
        future_d[i] = ret
    for key in future_d:  # 同步阻塞的
        print(key, future_d[key].result())

tp对象的map

map 只适合传递简单的参数,并且必须是一个可迭代的类型

from concurrent.futures import ProcessPoolExecutor
import os
import time


def func(a):
    print(os.getpid(), 'start', a[0], a[1])
    time.sleep(1)
    print(os.getpid(), 'end', a[0], a[1])
    return a[0] * a[1]


if __name__ == '__main__':
    tp = ProcessPoolExecutor(4)
    ret = tp.map(func, ((i, i + 1) for i in range(20)))  # 一般函数只接收一个参数,要想传入多个,使用元组方式
    for r in ret:
        print(r)

回调函数

当有一个结果需要进行处理时,都会绑定一个回调函数来处理,除非是得到所有结果之后才做处理,我们使用 把结果存入列表 遍历列表 的方式。

回调函数效率最高的。

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time


def func(a, b):
    print(current_thread().ident, 'start', a, b)
    time.sleep(1)
    print(current_thread().ident, 'end', a)
    return a * b


if __name__ == '__main__':
    tp = ThreadPoolExecutor(4)
    future_d = {}
    for i in range(20):  # 异步非阻塞的
        ret = tp.submit(func, a=i, b=i + 1)
        future_d[i] = ret
    for key in future_d:  # 同步阻塞的
        print(key, future_d[key].result())

上述代码,打印结果是按照顺序(0,1,2,3……),并不是谁先结束就打印谁。

使用回调函数以后,谁先执行完就打印谁,代码如下:

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time


def func(a, b):
    print(current_thread().ident, 'start', a, b)
    time.sleep(1)
    print(current_thread().ident, 'end', a)
    return a, a * b


def print_func(ret):  # 异步阻塞 每个任务都是各自阻塞各自,谁先执行完谁先打印
    print(ret.result())


if __name__ == '__main__':
    tp = ThreadPoolExecutor(4)
    for i in range(20):  # 异步非阻塞的
        ret = tp.submit(func, a=i, b=i + 1)  # [ret0, ret1, ..., ret19]
        ret.add_done_callback(print_func)  # 异步阻塞 [print_func, print_func,...,print_func]
        # 回调机制
        # 回调函数 给ret对象绑定一个回调函数,等待ret对应的任务有了结果之后立即调用print_func函数
        # 就可以对结果立即进行处理,而不用按照顺序接收结果处理结果

ret这个任务会在执行完毕的瞬间立即触发print_func函数,并且把任务的返回值对象传递到print_func做参数。

回调函数的例子

from concurrent.futures import ThreadPoolExecutor
import requests


def get_page(url):  # 访问网页,获取网页源代码,用线程池中的线程来操作
    respone = requests.get(url)
    if respone.status_code == 200:
        return {'url': url, 'text': respone.text}


def parse_page(res):  # 获取到字典结果之后,计算网页源代码的长度,把'https://www.baidu.com : 长度值'写到文件里,线程任务执行完毕之后绑定回调函数
    res = res.result()
    parse_res = 'url:<%s> size:[%s]\n' % (res['url'], len(res['text']))
    with open('db.txt', 'a') as f:
        f.write(parse_res)


if __name__ == '__main__':
    urls = [
        'https://www.baidu.com',
        'https://www.python.org',
        'https://www.openstack.org',
        'https://www.tencent.com/zh-cn',
        'http://www.sina.com.cn/'
    ]
    tp = ThreadPoolExecutor(4)
    for url in urls:
        ret = tp.submit(get_page, url)
        ret.add_done_callback(parse_page)  # 谁先回来谁就先把结果写进文件

# 不用回调函数:
    # 按照顺序获取网页,baidu python openstack tencent sina
    # 也只能按照顺序写
# 用上了回调函数
    # 按照顺序获取网页,baidu python openstack tencent sina
    # 哪一个网页先返回结果,就先执行哪个网页对应的回调函数(parse_page)

进程池线程池的应用场景

进程池:

场景:高计算的场景,没有IO操作(没有文件操作,没有数据库操作,没有网络操作,没有input);

进程的个数:[cpu_count*1, cpu_count*2]

线程池:
场景:爬虫

线程的个数:一般根据IO的比例定制,cpu_count*5

 

总结

到此这篇关于Python并发编程实例教程之线程的文章就介绍到这了,更多相关Python并发编程线程内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Python 相关文章推荐
python绘图库Matplotlib的安装
Jul 03 Python
Python 数据结构之堆栈实例代码
Jan 22 Python
Python调用C# Com dll组件实战教程
Oct 12 Python
批量将ppt转换为pdf的Python代码 只要27行!
Feb 26 Python
利用Python写一个爬妹子的爬虫
Jun 08 Python
python实现猜单词小游戏
May 22 Python
python SQLAlchemy 中的Engine详解
Jul 04 Python
Python统计时间内的并发数代码实例
Dec 28 Python
使用PyTorch将文件夹下的图片分为训练集和验证集实例
Jan 08 Python
python获取淘宝服务器时间的代码示例
Apr 22 Python
Python如何让字典保持有序排列
Apr 29 Python
python pandas 解析(读取、写入)CSV 文件的操作方法
Dec 24 Python
python迷宫问题深度优先遍历实例
Jun 20 #Python
Python虚拟环境virtualenv是如何使用的
Python机器学习之底层实现KNN
Jun 20 #Python
利用python进行数据加载
Jun 20 #Python
Python编解码问题及文本文件处理方法详解
浅谈Python协程asyncio
Jun 20 #Python
Python3接口性能测试实例代码
Jun 20 #Python
You might like
dedecms防止FCK乱格式化你的代码的修改方法
2007/03/17 PHP
php将url地址转化为完整的a标签链接代码(php为url地址添加a标签)
2014/01/17 PHP
php根据isbn书号查询amazon网站上的图书信息的示例
2014/02/13 PHP
php基于base64解码图片与加密图片还原实例
2014/11/03 PHP
54个提高PHP程序运行效率的方法
2015/07/19 PHP
php对微信支付回调处理的方法
2018/08/23 PHP
基于Laravel-admin 后台的自定义页面用法详解
2019/09/30 PHP
仿迅雷焦点广告效果(JQuery版)
2008/11/19 Javascript
javascript面向对象之Javascript 继承
2010/05/04 Javascript
ko knockoutjs动态属性绑定技巧应用
2012/11/14 Javascript
JS 实现图片直接下载示例代码
2013/07/22 Javascript
js控制文本框只输入数字和小数点的方法
2015/03/10 Javascript
jQuery绑定事件监听bind和移除事件监听unbind用法实例详解
2016/01/19 Javascript
基于AngularJs + Bootstrap + AngularStrap相结合实现省市区联动代码
2016/05/30 Javascript
PHP+jquery+ajax实现分页
2016/12/09 Javascript
js排序与重组的实例讲解
2017/08/28 Javascript
React Native日期时间选择组件的示例代码
2018/04/27 Javascript
Vue ElementUi同时校验多个表单(巧用new promise)
2018/06/06 Javascript
[01:25:09]2014 DOTA2国际邀请赛中国区预选赛 5 23 CIS VS DT第二场
2014/05/24 DOTA
[40:06]DOTA2亚洲邀请赛 4.3 突围赛 Liquid vs VGJ.T 第一场
2018/04/04 DOTA
[02:17]快乐加倍!DOTA2食人魔魔法师至宝+迎霜节活动上线
2019/12/22 DOTA
python操作mongodb根据_id查询数据的实现方法
2015/05/20 Python
python检查字符串是否是正确ISBN的方法
2015/07/11 Python
python下读取公私钥做加解密实例详解
2017/03/29 Python
python中编写函数并调用的知识点总结
2021/01/13 Python
Vrbo英国:预订度假屋
2020/08/19 全球购物
办公室综合文员岗位职责范本
2014/02/13 职场文书
新教师工作感言
2014/02/16 职场文书
餐饮总经理岗位职责
2014/03/07 职场文书
超市中秋节促销方案
2014/03/21 职场文书
幼儿园中班上学期评语
2014/04/18 职场文书
机关保密承诺书
2014/06/03 职场文书
技术员个人工作总结
2015/03/03 职场文书
2015年学校后勤工作总结
2015/04/08 职场文书
初中体育教学随笔
2015/08/15 职场文书
对讲机的最大通讯距离是多少
2022/02/18 无线电