在 Linux 上创建线程与进程的开销

Posted

技术标签:

【中文标题】在 Linux 上创建线程与进程的开销【英文标题】:Overhead in creating a thread vs process on Linux 【发布时间】:2019-02-10 00:33:40 【问题描述】:

我试图回答在 python 中创建线程与进程相比有多少开销的问题。我修改了一个类似问题的代码,该问题基本上运行一个具有两个线程的函数,然后运行具有两个进程的相同函数并报告时间。

import time, sys
NUM_RANGE = 100000000

from multiprocessing  import Process
import threading

def timefunc(f):
    t = time.time()
    f()
    return time.time() - t

def multiprocess():
    class MultiProcess(Process):
        def __init__(self):
            Process.__init__(self)

        def run(self):
            # Alter string + test processing speed
            for i in xrange(NUM_RANGE):
                a = 20 * 20


    for _ in xrange(300):
      MultiProcess().start()

def multithreading():
    class MultiThread(threading.Thread):
        def __init__(self):
            threading.Thread.__init__(self)

        def run(self):
            # Alter string + test processing speed
            for i in xrange(NUM_RANGE):
                a = 20 * 20

    for _ in xrange(300):
      MultiThread().start()

print "process run time" + str(timefunc(multiprocess))
print "thread run time" + str(timefunc(multithreading))

然后我得到 7.9s 用于多处理和 7.9s 用于多线程

我要回答的主要问题是,在 Linux 上专门针对数千个网络请求使用多线程或多处理是否合适。似乎根据这段代码,它们在启动时间方面是相同的,但也许进程在内存使用方面要重得多?

【问题讨论】:

收益递减 - 在单台机器上,300 'workers' 的值仅适用于特定的 低 CPU、高 IO 情况,甚至可能不适用然后(取决于共享锁和代码时间,例如;请参阅 GIL 以了解仅此一项可能如何改变进程与线程的选择)。示例中的“工作量”也是可疑和误导性的:即,是否所有的“工人”都同时排队?有了更合适的工作人员(例如“实际代码”),就有可能以随意的方式分析其他系统资源的使用情况.. 我敢于质疑 Python 2 是否也适合。也就是说,停止编写课程:youtube.com/watch?v=o9pEzgHorH0。线程和进程都不需要创建派生类。 【参考方案1】:

您的代码不适合对进程和线程之间的启动时间进行基准测试。多线程 Python 代码(在 CPython 中)意味着单核。在该线程持有全局解释器锁 (GIL) 期间,一个线程中的任何 Python 代码执行都将排除该进程中所有其他线程的继续执行。这意味着只要涉及 Python 字节码,您就只能与线程进行并发,而不是真正的并行。

您的示例主要是对特定的受 CPU 限制的工作负载性能进行基准测试(在紧密循环中运行计算),无论如何您都不会使用线程。如果你想测量创建开销,你必须从你的基准测试中去除除创建本身之外的任何东西(尽可能)。


TL;博士

启动线程(在 Ubuntu 18.04 上进行基准测试)比启动进程便宜很多倍。

与线程启动相比,以指定的 start_methods 启动进程需要:

分叉:约 33 倍长 forkserver:约 6693 倍长 生成:约 7558 倍长

完整的结果在底部。


基准测试

我最近升级到 Ubuntu 18.04 并测试了启动脚本,希望它更接近事实。请注意,此代码是 Python 3。

一些用于格式化和比较测试结果的实用程序:

# thread_vs_proc_start_up.py
import sys
import time
import pandas as pd
from threading import Thread
import multiprocessing as mp
from multiprocessing import Process, Pipe


def format_secs(sec, decimals=2) -> str:
    """Format subseconds.

    Example:
    >>>format_secs(0.000_000_001)
    # Out: '1.0 ns'
    """
    if sec < 1e-6:
        return f"sec * 1e9:.decimalsf ns"
    elif sec < 1e-3:
        return f"sec * 1e6:.decimalsf µs"
    elif sec < 1:
        return f"sec * 1e3:.decimalsf ms"
    elif sec >= 1:
        return f"sec:.decimalsf s"

def compare(value, base):
    """Return x-times relation of value and base."""
    return f"(value / base):.2fx"


def display_results(executor, result_series):
    """Display results for Executor."""
    exe_str = str(executor).split(".")[-1].strip('\'>')
    print(f"\nresults for exe_str:\n")

    print(result_series.describe().to_string(), "\n")
    print(f"Minimum with format_secs(result_series.min())")
    print("-" * 60)

下面的基准函数。对于n_runs 中的每一个测试,都会创建一个新管道。 一个新的进程或线程(一个执行程序)启动,目标函数calc_start_up_time 立即返回时间差。就是这样。

def calc_start_up_time(pipe_in, start):
    pipe_in.send(time.perf_counter() - start)
    pipe_in.close()


def run(executor, n_runs):

    results = []
    for _ in range(int(n_runs)):
        pipe_out, pipe_in = Pipe(duplex=False)
        exe = executor(target=calc_start_up_time, args=(pipe_in,
                                                    time.perf_counter(),))
        exe.start()
        # Note: Measuring only the time for exe.start() returning like:
        # start = time.perf_counter()
        # exe.start()
        # end = time.perf_counter()
        # would not include the full time a new process needs to become
        # production ready.
        results.append(pipe_out.recv())
        pipe_out.close()
        exe.join()

    result_series = pd.Series(results)
    display_results(executor, result_series)
    return result_series.min()

使用 start_method 和作为命令行参数传递的运行次数从终端开始构建。基准测试将始终运行以指定 start_method 启动的进程的n_runs(在 Ubuntu 18.04 上可用:fork、spawn、forkserver),然后与线程启动的n_runs 进行比较。结果集中在最小值上,因为它们显示了可能的速度。

if __name__ == '__main__':

    # Usage:
    # ------
    # Start from terminal with start_method and number of runs as arguments:
    #   $python thread_vs_proc_start_up.py fork 100
    #
    # Get all available start methods on your system with:
    # >>>import multiprocessing as mp
    # >>>mp.get_all_start_methods()

    start_method, n_runs = sys.argv[1:]
    mp.set_start_method(start_method)

    mins = []
    for executor in [Process, Thread]:
        mins.append(run(executor, n_runs))
    print(f"Minimum start-up time for processes takes "
          f"compare(*mins) "
          f"longer than for threads.")


结果

在我生锈的机器上使用n_runs=1000

# Ubuntu 18.04 start_method: fork
# ================================
results for Process:

count    1000.000000
mean        0.002081
std         0.000288
min         0.001466
25%         0.001866
50%         0.001973
75%         0.002268
max         0.003365 

Minimum with 1.47 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000054
std         0.000013
min         0.000044
25%         0.000047
50%         0.000051
75%         0.000058
max         0.000319 

Minimum with 43.89 µs
------------------------------------------------------------
Minimum start-up time for processes takes 33.41x longer than for threads.

# Ubuntu 18.04 start_method: spawn
# ================================

results for Process:

count    1000.000000
mean        0.333502
std         0.008068
min         0.321796
25%         0.328776
50%         0.331763
75%         0.336045
max         0.415568 

Minimum with 321.80 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000056
std         0.000016
min         0.000043
25%         0.000046
50%         0.000048
75%         0.000065
max         0.000231 

Minimum with 42.58 µs
------------------------------------------------------------
Minimum start-up time for processes takes 7557.80x longer than for threads.

# Ubuntu 18.04 start_method: forkserver
# =====================================


results for Process:

count    1000.000000
mean        0.295011
std         0.007157
min         0.287871
25%         0.291440
50%         0.293263
75%         0.296185
max         0.361581 

Minimum with 287.87 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000055
std         0.000014
min         0.000043
25%         0.000045
50%         0.000047
75%         0.000064
max         0.000251 

Minimum with 43.01 µs
------------------------------------------------------------
Minimum start-up time for processes takes 6693.44x longer than for threads.

【讨论】:

【参考方案2】:

这取决于......也许“两者”可能是您正在寻找的答案。

python 中的多进程使用 linux 中的标准 fork() 调用来复制主进程。在您的最小程序的情况下,这可能不是很多数据,但是根据最终程序的结构,可以说是更多的数据。在最小的情况下,进程内存开销非常小。

线程不会有这个内存开销问题,但是除了启动时间之外,它还有另一个潜在的问题,你可能不得不担心...... GIL。如果您的线程在很大程度上被阻塞以等待 I/O,则 GIL 可能不会成为问题,但是如果您只是像在测试中那样运行循环,则一次只能运行 2 个线程....

换句话说;即使您在测试中获得了相同的时间,但在幕后发生了很多事情,像这样的简单测试是无法捕捉到的。

一个正在运行的程序的正确答案可能是不担心启动时间,但它可能更多地依赖于

每个线程或进程要做什么? 它需要访问哪些内存和状态,锁定是否会成为问题? 在 python 中,GIL 是否会对工作负载产生问题(一次运行 2 个线程是否足以满足工作负载) 将进程占用空间乘以进程数是否为可接受的内存量

我遵循的一个基本经验法则是,如果线程/进程主要在 I/O 上被阻塞(等待网络流量或其他东西),请使用线程。如果您有更多计算量大的需求并且内存不是问题,请使用进程。

该规则的一个例外是我要如何处理进程或线程的内存和状态。当您开始谈论大量线程和此类进程时,您可能会考虑内存访问/锁定争用...

但实际上,如果没有更多数据,很难提出好的建议。并发编程是很多人都会做的事情之一,但很少有人真正理解得很好(根据我的经验)。

需要研究的一些额外内容可能是重组进程以需要更少的线程。通常,在制作网络服务器和客户端时,我最终会使用线程并有一个侦听器和发送者线程,它们要么阻塞在队列上,要么阻塞在等待某事做的套接字上。您可能希望减少仅提供队列的侦听器和发送者,从而限制开销。我认为 Python3.5+ 中有一个新的 asyncio 库也可以简化你的生活。

我知道我并没有真正回答你的问题,但我希望我提供了一些东西来查找和检查。

希望有帮助!

【讨论】:

【参考方案3】:

要回答您的问题,我们需要了解 Python 中的线程和多处理的一些基础知识。事实证明,问题不在于启动开销,而在于每个人如何将运行负载分配到系统资源上。

首先,python 中的线程与 Linux 中的线程不同。 Linux 为每个线程创建一个新的轻量级进程,这些进程可以在不同的 CPU 内核上运行,而 python 脚本及其线程在任何给定时刻都在同一个 CPU 内核中运行。如果你想在python中实现真正的多处理,你必须使用多处理接口。

为了演示以上内容,运行 Linux 系统监视器,选择资源选项卡,然后在不同的终端窗口中,尝试运行我在下面插入的两个代码 sn-ps 中的每一个。资源选项卡显示每个 CPU 内核的负载。

第二个重要问题是您希望同时处理数千个传入连接。您可能需要多处理接口,但是您可以容纳多少进程和连接可能会受到限制,无论是在 Linux 中配置还是在调度或资源方面的瓶颈,c.f.硬件。

如果您选择一次不让大量进程处于活动状态,则处理此问题的一种方法是创建固定数量的进程,将它们存储在列表中,然后将传入连接传递给它们他们进来了。当所有进程都忙时,你等待。为此,您至少需要一个计数信号量。

如果您想在连接进入时创建进程,您可以再次使用计数信号量来限制一次运行的进程数。您将计数信号量初始化为最大数量,为您创建的每个进程递减它,并在进程退出时递增它。如上所述,当您达到允许的最大进程数时,您等待。

好的,这里是线程和多处理的代码示例。第一个启动 5 个线程。第二个启动 5 个进程。您可以通过一次编辑来更改这些,以达到 100、1000 等。每个中的整数处理循环,让您看到 Linux 系统监控程序中的负载。

#!/usr/bin/python

# Parallel code with shared variables, using threads
from threading import Lock, Thread
from time import sleep

# Variables to be shared across threads
counter = 0
run = True
lock = Lock()

# Function to be executed in parallel
def myfunc():

    # Declare shared variables
    global run
    global counter
    global lock

    # Processing to be done until told to exit
    while run:
        n = 0
        for i in range(10000):
            n = n+i*i
        print( n )
        sleep( 1 )



        # Increment the counter
        lock.acquire()
        counter = counter + 1
        lock.release()

    # Set the counter to show that we exited
    lock.acquire()
    counter = -1
    lock.release()
    print( 'thread exit' )

# ----------------------------

# Launch the parallel function in a set of threads
tlist = []
for n in range(5):
    thread = Thread(target=myfunc)
    thread.start()
    tlist.append(thread)

# Read and print the counter
while counter < 5:
    print( counter )
    n = 0
    for i in range(10000):
        n = n+i*i
    print( n )
    #sleep( 1 )

# Change the counter    
lock.acquire()
counter = 0
lock.release()

# Read and print the counter
while counter < 5:
    print( counter )
    n = 0
    for i in range(10000):
        n = n+i*i
    print( n )
    #sleep( 1 )

# Tell the thread to exit and wait for it to exit
run = False

for thread in tlist:
    thread.join()

# Confirm that the thread set the counter on exit
print( counter )

这是多处理版本:

#!/usr/bin/python

from time import sleep
from multiprocessing import Process, Value, Lock

def myfunc(counter, lock, run):

    while run.value:
        sleep(1)
        n=0
        for i in range(10000):
            n = n+i*i
        print( n )
        with lock:
            counter.value += 1
            print( "thread %d"%counter.value )

    with lock:
        counter.value = -1
        print( "thread exit %d"%counter.value )

# -----------------------

counter = Value('i', 0)
run = Value('b', True)
lock = Lock()

plist = []
for n in range(5):
    p = Process(target=myfunc, args=(counter, lock, run))
    p.start()
    plist.append(p)


while counter.value < 5:
    print( "main %d"%counter.value )
    n=0
    for i in range(10000):
        n = n+i*i
    print( n )
    sleep(1)

with lock:
    counter.value = 0

while counter.value < 5:
    print( "main %d"%counter.value )
    sleep(1)

run.value = False

for p in plist:
    p.join()

print( "main exit %d"%counter.value)

【讨论】:

以上是关于在 Linux 上创建线程与进程的开销的主要内容,如果未能解决你的问题,请参考以下文章

什么是线程?线程与进程的区别?

Linux 操作系统原理 — 进程管理 — NUMA 架构中的多线程调度开销与性能优化

进程与线程的比较

22-进程与线程2

多线程,理论部分

进程与线程的区别