绿色线程(Greenlet)与线程的区别

156

我刚接触gevents和greenlets。 我找到了一些关于如何使用它们的好文档,但没有给出在什么情况下应该使用greenlets的理由!

  • 它们真正擅长什么?
  • 在代理服务器中使用它们是个好主意吗?
  • 为什么不用线程?

我不确定的是,如果它们基本上是协程,它们如何提供并发。


1
@Imran 这是关于Java中的绿色线程。我的问题是关于Python中的greenlet。我有什么遗漏吗? - Rsh
据我所知,Python 中的线程实际上并不是真正的并发,因为存在全局解释器锁。因此,这将归结为比较两种解决方案的开销。尽管我理解 Python 有几种实现,因此这可能并不适用于所有实现。 - didierc
3
CPython(现在也包括PyPy)不会并行解释Python(byte)代码(也就是说,不会真正地在两个不同的CPU核心上同时执行)。但是,Python程序所做的一切并不都在全局解释器锁(GIL)的控制下(常见例子是系统调用,包括I/O和故意释放GIL的C函数),而threading.Thread实际上是具有所有影响的操作系统线程。因此,情况真的不是那么简单。顺便说一句,据我所知,Jython没有GIL,PyPy也试图摆脱它。 - user395760
4个回答

226

Greenlets提供并发性但不提供并行性。并发是指代码可以独立于其他代码运行。并行是同时执行并发代码的过程。当用户空间中有大量工作需要完成时,特别是计算密集型任务,这种并行性尤为有用。并发性对于分解问题、使不同部分更容易地并行调度和管理非常有用。

Greenlets在网络编程中表现非常出色,其中与一个套接字的交互可以独立于与其他套接字的交互进行。这是并发的典型例子。由于每个greenlet都在自己的上下文中运行,因此您可以继续使用同步API而无需线程。这很好,因为线程在虚拟内存和内核开销方面非常昂贵,因此您可以使用线程实现的并发性显著较少。此外,由于全局解释器锁(GIL)的存在,Python中的线程比通常更昂贵和更受限制。替代并发性的项目通常是类似Twisted、libevent、libuv、node.js等,所有代码都共享相同的执行上下文,并注册事件处理程序。

对于编写代理,使用greenlets(通过gevent等适当的网络支持)是一个极好的选择,因为处理请求能够独立执行并应该按此方式编写。

Greenlets提供并发性,原因如前所述。并发不等于并行。通过在调用通常会阻塞当前线程的情况下隐藏事件注册并执行调度,像gevent这样的项目可以在不需要更改异步API的情况下暴露这种并发性,并且对您的系统造成的成本显著较少。


1
谢谢,只有两个小问题:1)是否可以将此解决方案与多进程结合使用以实现更高的吞吐量?2)我仍然不知道为什么要使用线程?我们可以将它们视为Python标准库中并发的天真和基本实现吗? - Rsh
6
  1. 是的,绝对可以。你不应该过早地这样做,但由于许多超出本问题范围的因素,使用多个进程为请求提供服务将提高吞吐量。
  2. 操作系统线程具有抢占式调度,并默认完全并行化。它们是Python的默认选项,因为Python公开了本地线程接口,并且线程是现代操作系统中并行性和并发性的最佳支持和最低公共分母。
- Matt Joiner
7
我应该提醒你,在线程不满足要求之前(通常是因为你正在处理大量并发连接,而线程计数或GIL会给你带来麻烦),你甚至不应该使用greenlets,即使在这种情况下,只有在没有其他选项可用的情况下才使用。Python标准库和大多数第三方库都期望通过线程实现并发,因此如果你通过greenlets提供并发,可能会出现奇怪的行为。 - Matt Joiner
@MattJoiner 我有以下函数,它读取大文件以计算 md5 校验和。我如何在这种情况下使用 gevent 以更快的速度读取?`import hashlibdef checksum_md5(filename): md5 = hashlib.md5() with open(filename,'rb') as f: for chunk in iter(lambda: f.read(8192), b''): md5.update(chunk) return md5.digest()` - Soumya

20

修正 @TemporalBeing 上面的答案,greenlets 并不比线程 "更快",而且为了解决并发问题而产生60000个线程是一种错误的编程技巧,适当的做法是使用一个小型线程池。以下是比较合理的比较方式(来自我在reddit 帖子中对人们引用这篇 SO 文章的回复)。

import gevent
from gevent import socket as gsock
import socket as sock
import threading
from datetime import datetime


def timeit(fn, URLS):
    t1 = datetime.now()
    fn()
    t2 = datetime.now()
    print(
        "%s / %d hostnames, %s seconds" % (
            fn.__name__,
            len(URLS),
            (t2 - t1).total_seconds()
        )
    )


def run_gevent_without_a_timeout():
    ip_numbers = []

    def greenlet(domain_name):
        ip_numbers.append(gsock.gethostbyname(domain_name))

    jobs = [gevent.spawn(greenlet, domain_name) for domain_name in URLS]
    gevent.joinall(jobs)
    assert len(ip_numbers) == len(URLS)


def run_threads_correctly():
    ip_numbers = []

    def process():
        while queue:
            try:
                domain_name = queue.pop()
            except IndexError:
                pass
            else:
                ip_numbers.append(sock.gethostbyname(domain_name))

    threads = [threading.Thread(target=process) for i in range(50)]

    queue = list(URLS)
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    assert len(ip_numbers) == len(URLS)

URLS_base = ['www.google.com', 'www.example.com', 'www.python.org',
             'www.yahoo.com', 'www.ubc.ca', 'www.wikipedia.org']

for NUM in (5, 50, 500, 5000, 10000):
    URLS = []

    for _ in range(NUM):
        for url in URLS_base:
            URLS.append(url)

    print("--------------------")
    timeit(run_gevent_without_a_timeout, URLS)
    timeit(run_threads_correctly, URLS)

这里是一些结果:
--------------------
run_gevent_without_a_timeout / 30 hostnames, 0.044888 seconds
run_threads_correctly / 30 hostnames, 0.019389 seconds
--------------------
run_gevent_without_a_timeout / 300 hostnames, 0.186045 seconds
run_threads_correctly / 300 hostnames, 0.153808 seconds
--------------------
run_gevent_without_a_timeout / 3000 hostnames, 1.834089 seconds
run_threads_correctly / 3000 hostnames, 1.569523 seconds
--------------------
run_gevent_without_a_timeout / 30000 hostnames, 19.030259 seconds
run_threads_correctly / 30000 hostnames, 15.163603 seconds
--------------------
run_gevent_without_a_timeout / 60000 hostnames, 35.770358 seconds
run_threads_correctly / 60000 hostnames, 29.864083 seconds

每个人对于Python中的非阻塞IO都有一个误解,即Python解释器可以比网络连接本身更快地处理从套接字中检索结果的工作。虽然在某些情况下确实如此,但这种情况并不像人们想象的那么频繁,因为Python解释器真的很慢。在我的博客文章中,我展示了一些图形化配置文件,即使是非常简单的事情,如果你处理的是像数据库或DNS服务器这样的清晰快速的网络访问,这些服务可能会比Python代码更快地处理成千上万个这样的连接。

我稍微修改了你的代码,实际执行了 socket.connect 操作而不是仅仅将主机名解析为 IP 地址,使用 greenlet 代码速度更快(每秒约 10000 个套接字连接速度提高了 3 倍)。在使用 greenlets 时,我必须使用 gevent.pool.Pool(50) 来限制打开套接字的数量。我认为使用 socket.connect 进行测试是网络编程中使用 greenlets 更现实的用法,而不是仅仅解析主机名。 - smac89

16

参考@Max的回答并加入一些针对扩展的相关性,你就可以看到区别。我通过将URL更改为以下内容来实现这一点:

URLS_base = ['www.google.com', 'www.example.com', 'www.python.org', 'www.yahoo.com', 'www.ubc.ca', 'www.wikipedia.org']
URLS = []
for _ in range(10000):
    for url in URLS_base:
        URLS.append(url)

由于我在进行到500次迭代之前就遇到了问题,不得不放弃多进程版本;但在进行了10,000次迭代后:

Using gevent it took: 3.756914
-----------
Using multi-threading it took: 15.797028

所以你可以看到使用gevent时I/O存在一些显著的差异


12
直译:完全不正确的做法是生成60000个本地线程或进程来完成工作,这个测试没有展示任何东西(你是否在gevent.joinall()调用中去掉了超时?)。尝试使用约50个线程的线程池,可以参考我的回答:https://dev59.com/DWUp5IYBdhLWcg3wEEM6#51932442。通俗易懂版:生成60000个本地线程或进程来完成工作是完全错误的做法。这个测试并没有展示出有用的结果(请问你有没有将gevent.joinall()中的超时时间去掉?)。建议使用一个包含大约50个线程的线程池来处理任务,可以参考我在这里给出的回答:https://dev59.com/DWUp5IYBdhLWcg3wEEM6#51932442。 - zzzeek

7

这很有趣,可以进行分析。 以下是一个代码示例,用于比较greenlets和多进程池(multiprocessing pool)以及多线程的性能:

import gevent
from gevent import socket as gsock
import socket as sock
from multiprocessing import Pool
from threading import Thread
from datetime import datetime

class IpGetter(Thread):
    def __init__(self, domain):
        Thread.__init__(self)
        self.domain = domain
    def run(self):
        self.ip = sock.gethostbyname(self.domain)

if __name__ == "__main__":
    URLS = ['www.google.com', 'www.example.com', 'www.python.org', 'www.yahoo.com', 'www.ubc.ca', 'www.wikipedia.org']
    t1 = datetime.now()
    jobs = [gevent.spawn(gsock.gethostbyname, url) for url in URLS]
    gevent.joinall(jobs, timeout=2)
    t2 = datetime.now()
    print "Using gevent it took: %s" % (t2-t1).total_seconds()
    print "-----------"
    t1 = datetime.now()
    pool = Pool(len(URLS))
    results = pool.map(sock.gethostbyname, URLS)
    t2 = datetime.now()
    pool.close()
    print "Using multiprocessing it took: %s" % (t2-t1).total_seconds()
    print "-----------"
    t1 = datetime.now()
    threads = []
    for url in URLS:
        t = IpGetter(url)
        t.start()
        threads.append(t)
    for t in threads:
        t.join()
    t2 = datetime.now()
    print "Using multi-threading it took: %s" % (t2-t1).total_seconds()

以下是结果:

Using gevent it took: 0.083758
-----------
Using multiprocessing it took: 0.023633
-----------
Using multi-threading it took: 0.008327

我认为Greenlet声称它不像多线程库那样受GIL的限制。此外,Greenlet文档表示它是为网络操作而设计的。对于网络密集型操作,线程切换是可以接受的,你会发现多线程方法非常快。 此外,最好使用Python的官方库;我在Windows上尝试安装Greenlet时遇到了dll依赖问题,所以我在Linux虚拟机上运行了这个测试。 始终尝试编写可在任何计算机上运行的代码。


25
请注意,getsockbyname 会在操作系统级别缓存结果(至少在我的机器上是这样)。当对先前未知或已过期的 DNS 进行调用时,它将实际执行网络查询,这可能需要一些时间。当对刚刚解析的主机名进行调用时,它将更快地返回答案。因此,您的测量方法在这里存在缺陷。这就解释了您奇怪的结果 - gevent 实际上不可能比多线程差那么多 - 在 VM 级别上都不是真正的并行。 - KT.
1
@KT。这是一个很好的观点。你需要多次运行测试并取平均数、众数和中位数来获得良好的图片。还要注意路由器缓存协议的路由路径,在不缓存路由路径的情况下,您可能会从不同的 DNS 路由路径流量中获得不同的延迟。而 DNS 服务器有很强的缓存能力。使用 time.clock()测量线程可能更好,其中使用 CPU 周期而不是受网络硬件延迟影响。这可以消除其他操作系统服务潜入并添加您的测量时间。 - DevPlayer
哦,而且你可以在这三个测试之间在操作系统层面上运行DNS刷新,但这只能减少本地DNS缓存中的错误数据。 - DevPlayer
是的。运行这个清理过的版本:https://paste.ubuntu.com/p/pg3KTzT2FG/ 我得到了几乎相同的时间... using_gevent() 421.442985535ms using_multiprocessing() 394.540071487ms using_multithreading() 402.48298645ms - sehe
我认为OSX正在进行DNS缓存,但在Linux上这不是一个“默认”的事情:https://dev59.com/9mgu5IYBdhLWcg3w3ara#11021207,所以是的,在低并发级别下,由于解释器开销,greenlets会更糟糕。 - zzzeek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接