软件工程课堂讨论一

从运行状态到阻塞状态有等待锁的情况吗?

当一个线程试图获取一个锁(如 synchronized 块或 ReentrantLock),如果该锁已经被其他线程持有,当前线程无法立即获取锁时,就会从运行状态(Running)转换为阻塞状态(Blocked),直到锁被释放。

文章最后部分有两个讨论:

  • 获取到锁的线程为什么不直接执行到结束,这样就不会占用锁了?
  • 为什么不在调度线程前检测这个线程要用到的锁有没有被占用从而判断要不要调度该线程?

yield的使用场景?

yield在Python和Java中的作用完全不同。

Python中的 yield:

在 Python 中,yield 是一种用于定义生成器(Generator)的关键字,它的作用是将函数变成一个生成器函数。生成器函数在调用时不会一次性执行完毕,而是返回一个生成器对象,可以在迭代过程中多次暂停并继续。

使用场景:

  • 迭代大数据集合:当处理的数据量非常大时,如果使用普通的 return 返回整个集合,会导致内存占用过高。使用 yield 可以让函数每次返回一个数据,迭代时才真正计算或生成下一个数据。例如,从文件中逐行读取数据,或生成一个无限序列时,yield 可以显著减少内存使用。
  • 简化迭代器实现:Python 中可以通过 yield 简单地创建一个自定义的迭代器,而不需要实现 __iter____next__ 方法。生成器函数使用 yield 暂停并返回一个值,下次迭代时从上次暂停的地方继续执行。
  • 流水线数据处理:在数据流处理的场景中,例如从某个源头获取数据,逐步处理并将结果传递给下游,yield 可以用于构建数据管道。这种方式能够按需生成数据,节省内存和处理时间。

Java 中的 yield:

在 Java 中,yield 这个关键字是 Thread 类的一个静态方法,用于线程调度。Java 的 Thread.yield() 方法使当前线程将 CPU 的执行权暂时让给其他同样处于可运行状态的线程。

  • sleep执行后线程进入阻塞状态
  • yield执行后线程进入就绪状态
  • join执行后线程进入阻塞状态

yield()应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。

所以说yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。

使用场景:

  • 让步 CPU 时间片:当当前线程执行到某个位置,可以适当让出 CPU,给其他优先级相同的线程执行的机会时,可以调用 Thread.yield()。这并不意味着当前线程会立刻进入阻塞状态,只是让操作系统重新调度当前线程和其他可运行线程。使用 yield 通常是为了优化多线程的执行效率,在某些高并发环境中,有助于避免一个线程独占 CPU 资源。
  • 测试和调试目的:在调试多线程代码时,有时为了观察线程调度的行为,可以插入 yield(),以便更清晰地观察不同线程的执行顺序。在这种情况下,yield() 可以帮助程序员更好地理解多线程程序的行为。
  • 配合轻量级任务:在编写一些不需要长时间运行的轻量级任务时,可以使用 yield() 来避免某个线程在循环或任务中长时间占用 CPU,从而提高系统资源的利用率。

不使用yield发生IO操作/远程调用会自动进入阻塞状态吗?

其实这个问题问出来是有问题的。

首先,yield在Python和Java中的作用完全不同,我们这里并没有明确指出我们讨论的是哪一个。当然了,依照问题提出的场景,我们可以确定这个问题更准确的版本是——在用Python写的爬虫中,如果我不使用yield,当发生IO操作/远程调用会自动进入阻塞状态吗?

当然,这样问的话又引出了第二个问题。那就是在Python中yield是一种用于定义生成器的关键字,是实现异步操作的,与线程调度完完全全没有关系。所以对于这个问题,不管你使用还是不使用yield,都不能回答发生IO操作/远程调用是否会自动进入阻塞状态。

那对于发生IO操作/远程调用是否会自动进入阻塞状态这个问题来说,答案是只要你使用了线程,就会在该线程发生IO操作/远程调用时自动进入阻塞状态,也即会自动进行线程调度。

对于提出错误问题的原因有二:

  • 一是把Python和Java中的yield搞混淆了。
  • 二是把异步编程与线程调度的概念搞混淆了。

文章最后部分有两个讨论:

  • 在Python写的爬虫里面会用到yield吗?
  • 异步操作和线程调度的区别

一些讨论

1. 获取到锁的线程为什么不直接执行到结束,这样就不会占用锁了?

  1. 线程执行过程中被操作系统挂起:
    • 线程的执行并不是连续的,它受限于操作系统的线程调度机制。在多任务操作系统中,CPU 会在多个线程之间进行时间片轮转调度。
    • 假设 Thread A 获得了锁并开始执行,但在执行过程中,操作系统把它的时间片分配给了其他线程,此时 Thread A 被挂起。
    • 尽管 Thread A 还没有完成对锁的操作,但它的执行被暂停了,锁依然被它持有,其他线程如 Thread B 只能等待,直到操作系统再次调度 Thread A 执行并完成释放锁的操作。
  2. 锁保护的代码块执行时间过长:
    • 如果 Thread A 在持有锁时,执行的受保护的代码块非常耗时,例如需要进行复杂的计算、文件读写操作、数据库访问等,就会导致它占用锁的时间变长。
    • 这种情况下,其他线程在 Thread A 完成这些耗时操作之前,都无法获取到锁,只能进入阻塞状态等待。
  3. 线程在持有锁期间发生阻塞操作:
    • 如果 Thread A 在持有锁时,执行了可能会阻塞的操作,比如:
      • 等待 I/O 操作完成(如读取文件、网络请求)。
      • 调用了 sleep() 方法或其他可能导致线程暂停的操作。
    • 这会导致 Thread A 在持有锁的情况下等待外部资源,而此时锁不能被释放,其他线程就只能等待。
  4. 代码设计不合理:
    • 有时候,程序设计者可能没有合理地设计锁的粒度,即锁住的代码块过大,导致持有锁的时间变长。
    • 比如,某个方法中的很多操作其实不需要加锁,但是由于整个方法都被 synchronized 关键字修饰,导致所有操作都必须等到锁释放后才能执行。
    • 更好的做法是缩小锁的粒度,只在必要的地方使用锁,这样可以减少锁的持有时间,从而提高并发性能。
  5. 死锁问题:
    • 在某些情况下,可能出现死锁,即多个线程相互等待对方释放锁,导致所有线程都被阻塞,无法继续执行。
    • 例如,Thread A 持有锁 Lock1,等待 Lock2,而 Thread B 持有 Lock2,等待 Lock1。这就会导致两者相互等待,永远无法完成。
  6. 如何优化锁的使用:
    • 缩小锁的粒度:只对真正需要保护的代码段加锁,尽量减少锁的使用范围。
    • 使用更高效的锁机制:如 ReentrantLock 提供了更多的灵活性,还可以用 tryLock() 方法来避免无限制等待。
    • 避免持有锁时进行耗时操作:例如,尽量不要在持有锁的代码段中进行 I/O 操作。
    • 使用读写锁(如 ReadWriteLock):如果有大量的读操作,可以使用读写锁来提高性能,让多个读线程并发访问,而写线程会独占。

2. 为什么不在调度线程前检测这个线程要用到的锁有没有被占用从而判断要不要调度该线程?

  1. 锁的持有状态是动态的:
    • 锁的状态会随着线程执行过程中的操作而不断变化。一个锁可能在某一时刻被占用,但在另一个时刻就释放了。操作系统在调度线程时,如果预先检测锁状态,就可能在做出调度决定时,锁的状态已经改变了。
    • 举例来说,如果操作系统在某个瞬间检测到 Thread A 想要使用的锁 Lock1Thread B 占用,因此不调度 Thread A,但这时 Thread B 可能正好释放了 Lock1。如果再重新调度 Thread A,就会引入额外的延迟。
  2. 线程的调度和锁管理分属不同层次:
    • 线程调度是由操作系统的内核负责的,而锁的管理则往往由用户级的程序或线程库(如 Java 的 synchronizedReentrantLock)来实现。
    • 调度器不直接知道一个线程将要访问哪些具体资源,它只是基于线程的优先级、状态(如就绪、运行)等进行调度。而锁的管理发生在更高层次的应用逻辑中,操作系统不会对应用层的锁状态进行管理。
    • 为了让调度器了解每一个线程在未来会访问哪些锁,并在调度前进行检查,需要建立复杂的机制,这会大大增加操作系统的复杂性和调度开销。
  3. 预判锁的使用并不总是可靠:
    • 在多线程环境中,线程的执行顺序和锁的请求顺序是不可预知的。提前检测一个线程是否需要使用某个锁,实际上是对未来执行路径的预测。这个预测不仅复杂,而且可能并不准确。
    • 线程的逻辑中,锁的使用通常是由程序执行路径决定的,例如只有在满足某个条件时,才会去获取某个锁。操作系统很难提前判断某个线程在执行过程中一定会请求某个锁。
  4. 效率问题:调度开销和频繁检测:
    • 假设操作系统在每次调度前都要检查一个线程要使用的锁是否被占用,那么对于大量的线程和锁,这种检查操作会极大地增加调度的开销。
    • 频繁的锁状态检查会消耗更多的系统资源,尤其是当有大量线程频繁竞争少数几个锁时,这种机制可能会导致更多的系统开销,而不是提升整体的效率。
  5. 现代调度器的设计已经优化了线程的等待:
    • 现代操作系统的调度器已经优化了线程在等待状态下的调度。例如,当一个线程因为等待锁进入阻塞状态时,调度器会将它放入等待队列中,而不会浪费 CPU 资源反复检查。
    • 当锁释放时,调度器会及时唤醒等待的线程,并尽快让其获取锁继续执行。这样做的好处是,可以让其他不需要该锁的线程在这段时间内利用 CPU 资源,提升系统的整体效率。
  6. 锁的竞争是正常的并发现象:
    • 锁竞争是多线程编程中的一种正常现象,操作系统通过调度算法和锁管理机制,尽量让锁的竞争对系统性能的影响降到最低。提前预判并不总是最佳选择,因为在多数情况下,线程在获取锁后能够快速完成操作。
    • 通过设计更高效的并发程序,例如减少临界区的代码量、合理使用锁的粒度、避免不必要的锁定操作,往往可以更有效地解决锁竞争问题。

3. 在Python写的爬虫里面会用到yield吗?

答案是是的,但是在 Python 写的爬虫中,yield 是被用来进行异步操作和作为生成器的。下面是 Python 爬虫中使用 yield 的一些常见场景及其优势:

1. 逐步获取网页数据

  • 在爬取大量网页时,如果一次性将所有数据加载到内存中,会占用大量内存。使用 yield 可以在每次请求到数据时返回一部分,允许逐步处理数据,而不是一次性获取所有内容。
  • 这在处理分页数据时尤为常见,比如爬取多页列表数据时,通过生成器逐页获取。

示例:逐页爬取网页数据

import requests

def fetch_pages(url, max_pages=5):
    page = 1
    while page <= max_pages:
        response = requests.get(f"{url}?page={page}")
        if response.status_code == 200:
            yield response.content  # 使用 yield 返回每一页的内容
        else:
            break
        page += 1

# 使用生成器逐页处理数据
for page_content in fetch_pages("https://example.com/data"):
    # 对每页的内容进行处理
    print(f"Processing page: {page_content[:100]}")  # 打印前100个字符

在这个例子中,fetch_pages 函数每次爬取一个页面的内容,使用 yield 返回该页数据,调用方可以逐页处理。这种方式节省了内存,因为不需要一次性保存所有页的数据。

2. 逐个解析数据

  • 爬虫抓取到大量数据时,可以使用生成器逐个解析和处理每条记录。通过 yield,你可以在读取数据的同时进行解析,避免一次性处理所有数据。
  • 这对于解析 JSON 格式的 API 数据或 HTML 页面中的条目非常有用。

示例:逐条解析 JSON 数据

import requests
import json

def fetch_items(api_url):
    response = requests.get(api_url)
    data = json.loads(response.content)
    for item in data.get('results', []):
        yield item  # 使用 yield 返回每一条记录

# 逐条处理数据
for item in fetch_items("https://example.com/api/data"):
    print(item)

这个例子中,fetch_items 函数使用 yield 将解析后的每条记录逐条返回。调用方可以一条条处理数据,适合处理数据量大的场景。

3. 实现异步爬取与处理

  • 在协程和异步编程中,yield 可以配合 asyncioaiohttp 等库使用,构建异步生成器。这种方式可以同时爬取多个网页,而不必等待每一个网页的响应,从而提高爬取速度。
  • 虽然在 Python 3.5 以后,asyncawait 更为常用,但 yield 仍然可以在某些特定场景中与异步生成器结合使用。

示例:异步爬虫中的生成器

import aiohttp
import asyncio

async def fetch(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            yield await response.text()  # 使用 yield 返回网页内容

async def main(urls):
    for url in urls:
        async for page_content in fetch(url):
            print(f"Page content: {page_content[:100]}")  # 打印前100个字符

urls = ["https://example.com/page1", "https://example.com/page2"]
asyncio.run(main(urls))

在这个例子中,fetch 是一个异步生成器函数,它使用 yield 返回每个请求到的网页内容。这种方式能够更高效地爬取多个网页。

4. 数据管道与流水线处理

  • 爬虫的数据处理往往需要多个步骤,比如下载页面、解析内容、提取信息等。使用 yield 可以在这些步骤之间构建数据管道,使得数据在各个步骤之间流动。
  • 这种方式让爬虫的逻辑更加清晰,并且可以按需生成和处理数据。

示例:构建数据管道

def download_pages(urls):
    for url in urls:
        response = requests.get(url)
        if response.status_code == 200:
            yield response.content

def parse_pages(pages):
    for page in pages:
        # 假设这里使用 BeautifulSoup 解析 HTML
        yield page[:100]  # 简单返回前100个字符作为示例

# 组合生成器
urls = ["https://example.com/page1", "https://example.com/page2"]
pages = download_pages(urls)
parsed_data = parse_pages(pages)

# 逐步处理数据
for data in parsed_data:
    print(f"Parsed data: {data}")

在这个例子中,download_pagesparse_pages 都是生成器,使用 yield 形成数据管道。这使得每个步骤都能逐步处理数据,避免一次性占用大量内存。

4. 异步操作和线程调度的区别

简单来说,异步操作线程调度会在某种程度上有一些表现相似,但它们在设计理念、工作方式和适用场景上仍然存在显著的区别。

由于这是一个比较复杂的问题,而且撰写这篇文章的时候也比较晚了(2024年10月11日星期五02:59),所以这部分是待补充状态。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇